@hyperframes/studio 0.5.0-alpha.13 → 0.5.0-alpha.15

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 (33) hide show
  1. package/dist/assets/{hyperframes-player-vibA20NC.js → hyperframes-player-Cd8vYWxP.js} +2 -2
  2. package/dist/assets/index-DFLVGWTx.js +106 -0
  3. package/dist/assets/index-mXJ-UH9F.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +785 -377
  7. package/src/captions/generator.test.ts +19 -0
  8. package/src/captions/generator.ts +9 -2
  9. package/src/captions/hooks/useCaptionSync.ts +6 -1
  10. package/src/captions/parser.test.ts +14 -0
  11. package/src/captions/parser.ts +1 -0
  12. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  13. package/src/components/editor/DomEditOverlay.tsx +970 -115
  14. package/src/components/editor/PropertyPanel.tsx +91 -83
  15. package/src/components/editor/domEditing.test.ts +161 -29
  16. package/src/components/editor/domEditing.ts +84 -113
  17. package/src/components/editor/manualEdits.test.ts +945 -0
  18. package/src/components/editor/manualEdits.ts +1397 -0
  19. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  20. package/src/components/editor/manualOffsetDrag.ts +307 -0
  21. package/src/components/renders/RenderQueue.tsx +10 -3
  22. package/src/hooks/usePersistentEditHistory.test.ts +1 -0
  23. package/src/hooks/usePersistentEditHistory.ts +3 -2
  24. package/src/player/components/CompositionThumbnail.test.ts +1 -1
  25. package/src/player/components/CompositionThumbnail.tsx +1 -1
  26. package/src/player/components/Player.tsx +54 -9
  27. package/src/player/hooks/useTimelinePlayer.test.ts +1 -0
  28. package/src/utils/clipboard.test.ts +1 -0
  29. package/src/utils/frameCapture.ts +3 -1
  30. package/src/utils/projectRouting.test.ts +87 -0
  31. package/src/utils/projectRouting.ts +27 -0
  32. package/dist/assets/index-JhhmFie-.js +0 -105
  33. package/dist/assets/index-KioPDrX6.css +0 -1
@@ -1,35 +1,88 @@
1
1
  import { memo, useMemo, useRef, useState, type RefObject } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { type DomEditSelection, findElementForSelection } from "./domEditing";
4
+ import {
5
+ applyManualOffsetDragCommit,
6
+ applyManualOffsetDragDraft,
7
+ createManualOffsetDragMember,
8
+ endManualOffsetDragMembers,
9
+ restoreManualOffsetDragMembers,
10
+ type ManualOffsetDragMember,
11
+ } from "./manualOffsetDrag";
12
+ import {
13
+ applyStudioBoxSize,
14
+ applyStudioBoxSizeDraft,
15
+ applyStudioRotation,
16
+ applyStudioRotationDraft,
17
+ beginStudioManualEditGesture,
18
+ captureStudioBoxSize,
19
+ captureStudioPathOffset,
20
+ captureStudioRotation,
21
+ endStudioManualEditGesture,
22
+ isStudioManualEditGestureCurrent,
23
+ readStudioBoxSize,
24
+ readStudioRotation,
25
+ restoreStudioBoxSize,
26
+ restoreStudioPathOffset,
27
+ restoreStudioRotation,
28
+ type StudioBoxSizeSnapshot,
29
+ type StudioPathOffsetSnapshot,
30
+ type StudioRotationSnapshot,
31
+ } from "./manualEdits";
4
32
 
5
33
  interface OverlayRect {
6
34
  left: number;
7
35
  top: number;
8
36
  width: number;
9
37
  height: number;
10
- scaleX: number;
11
- scaleY: number;
38
+ editScaleX: number;
39
+ editScaleY: number;
40
+ }
41
+
42
+ interface GroupOverlayItem {
43
+ key: string;
44
+ selection: DomEditSelection;
45
+ element: HTMLElement;
46
+ rect: OverlayRect;
47
+ }
48
+
49
+ export interface DomEditGroupPathOffsetCommit {
50
+ selection: DomEditSelection;
51
+ next: { x: number; y: number };
12
52
  }
13
53
 
14
54
  interface DomEditOverlayProps {
15
55
  iframeRef: RefObject<HTMLIFrameElement | null>;
56
+ activeCompositionPath: string | null;
16
57
  selection: DomEditSelection | null;
58
+ groupSelections?: DomEditSelection[];
59
+ hoverSelection: DomEditSelection | null;
17
60
  allowCanvasMovement?: boolean;
18
61
  onCanvasMouseDown: (
19
62
  event: React.MouseEvent<HTMLDivElement>,
20
63
  options?: { preferClipAncestor?: boolean },
21
64
  ) => void;
22
- onCanvasDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => void;
23
- onSelectedDoubleClick: () => void;
65
+ onCanvasPointerMove: (
66
+ event: React.PointerEvent<HTMLDivElement>,
67
+ options?: { preferClipAncestor?: boolean },
68
+ ) => DomEditSelection | null;
69
+ onCanvasPointerLeave: () => void;
70
+ onSelectionChange: (
71
+ selection: DomEditSelection,
72
+ options?: { revealPanel?: boolean; additive?: boolean },
73
+ ) => void;
24
74
  onBlockedMove: (selection: DomEditSelection) => void;
25
- onMoveCommit: (
75
+ onManualDragStart?: () => void;
76
+ onPathOffsetCommit: (
26
77
  selection: DomEditSelection,
27
- next: { left: number; top: number },
78
+ next: { x: number; y: number },
28
79
  ) => Promise<void> | void;
29
- onResizeCommit: (
80
+ onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void;
81
+ onBoxSizeCommit: (
30
82
  selection: DomEditSelection,
31
83
  next: { width: number; height: number },
32
84
  ) => Promise<void> | void;
85
+ onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
33
86
  }
34
87
 
35
88
  function toOverlayRect(
@@ -48,22 +101,82 @@ function toOverlayRect(
48
101
  if (!rootWidth || !rootHeight) return null;
49
102
 
50
103
  const elementRect = element.getBoundingClientRect();
51
- const scaleX = iframeRect.width / rootWidth;
52
- const scaleY = iframeRect.height / rootHeight;
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
+ });
53
116
 
54
117
  return {
55
- left: iframeRect.left - overlayRect.left + elementRect.left * scaleX,
56
- top: iframeRect.top - overlayRect.top + elementRect.top * scaleY,
57
- width: elementRect.width * scaleX,
58
- height: elementRect.height * scaleY,
59
- scaleX,
60
- scaleY,
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,
61
124
  };
62
125
  }
63
126
 
64
- type GestureKind = "drag" | "resize";
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";
65
175
  const BLOCKED_MOVE_THRESHOLD_PX = 4;
176
+ const MIN_RESIZE_EDGE_PX = 20;
66
177
  const OVERLAY_RECT_EPSILON_PX = 0.5;
178
+ const ROTATION_COMMIT_EPSILON_DEGREES = 0.05;
179
+ const ROTATION_SNAP_DEGREES = 15;
67
180
 
68
181
  function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean {
69
182
  if (a === b) return true;
@@ -73,8 +186,55 @@ function rectsEqual(a: OverlayRect | null, b: OverlayRect | null): boolean {
73
186
  Math.abs(a.top - b.top) < OVERLAY_RECT_EPSILON_PX &&
74
187
  Math.abs(a.width - b.width) < OVERLAY_RECT_EPSILON_PX &&
75
188
  Math.abs(a.height - b.height) < OVERLAY_RECT_EPSILON_PX &&
76
- Math.abs(a.scaleX - b.scaleX) < 0.001 &&
77
- Math.abs(a.scaleY - b.scaleY) < 0.001
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)),
78
238
  );
79
239
  }
80
240
 
@@ -89,31 +249,120 @@ function selectionCacheKey(
89
249
  ].join("|");
90
250
  }
91
251
 
92
- function restoreInlineStyle(
93
- element: HTMLElement,
94
- property: "left" | "top" | "width" | "height",
95
- value: string,
96
- ) {
97
- if (value) element.style.setProperty(property, value);
98
- else element.style.removeProperty(property);
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;
99
335
  }
100
336
 
101
337
  interface GestureState {
102
338
  kind: GestureKind;
339
+ mode: "path-offset" | "box-size" | "rotation";
340
+ selection: DomEditSelection;
103
341
  startX: number;
104
342
  startY: number;
105
- initialStyleLeft: string;
106
- initialStyleTop: string;
343
+ centerX: number;
344
+ centerY: number;
345
+ initialPathOffset: StudioPathOffsetSnapshot;
346
+ initialRotation: StudioRotationSnapshot;
347
+ initialBoxSize: StudioBoxSizeSnapshot;
348
+ pathOffsetMember?: ManualOffsetDragMember;
107
349
  originLeft: number;
108
350
  originTop: number;
109
351
  originWidth: number;
110
352
  originHeight: number;
111
- actualLeft: number;
112
- actualTop: number;
113
353
  actualWidth: number;
114
354
  actualHeight: number;
115
- scaleX: number;
116
- scaleY: 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[];
117
366
  }
118
367
 
119
368
  interface BlockedMoveState {
@@ -123,36 +372,76 @@ interface BlockedMoveState {
123
372
  notified: boolean;
124
373
  }
125
374
 
375
+ type ResolvedElementRef = {
376
+ current: { key: string; element: HTMLElement } | null;
377
+ };
378
+
126
379
  export const DomEditOverlay = memo(function DomEditOverlay({
127
380
  iframeRef,
381
+ activeCompositionPath,
128
382
  selection,
383
+ groupSelections = [],
384
+ hoverSelection,
129
385
  allowCanvasMovement = true,
130
386
  onCanvasMouseDown,
131
- onCanvasDoubleClick,
132
- onSelectedDoubleClick,
387
+ onCanvasPointerMove,
388
+ onCanvasPointerLeave,
389
+ onSelectionChange,
133
390
  onBlockedMove,
134
- onMoveCommit,
135
- onResizeCommit,
391
+ onManualDragStart,
392
+ onPathOffsetCommit,
393
+ onGroupPathOffsetCommit,
394
+ onBoxSizeCommit,
395
+ onRotationCommit,
136
396
  }: DomEditOverlayProps) {
137
397
  const overlayRef = useRef<HTMLDivElement | null>(null);
138
398
  const boxRef = useRef<HTMLDivElement | null>(null);
139
399
  const [overlayRect, setOverlayRect] = useState<OverlayRect | null>(null);
400
+ const [hoverRect, setHoverRect] = useState<OverlayRect | null>(null);
401
+ const [groupOverlayItems, setGroupOverlayItems] = useState<GroupOverlayItem[]>([]);
140
402
  const gestureRef = useRef<GestureState | null>(null);
403
+ const groupGestureRef = useRef<GroupGestureState | null>(null);
141
404
  const blockedMoveRef = useRef<BlockedMoveState | null>(null);
142
405
  const suppressNextBoxClickRef = useRef(false);
406
+ const suppressNextBoxMouseDownRef = useRef(false);
407
+ const suppressNextOverlayMouseDownRef = useRef(false);
143
408
  const rafPausedRef = useRef(false);
144
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());
145
412
 
146
413
  const selectionRef = useRef(selection);
147
414
  selectionRef.current = selection;
415
+ const activeCompositionPathRef = useRef(activeCompositionPath);
416
+ activeCompositionPathRef.current = activeCompositionPath;
417
+ const groupSelectionsRef = useRef(groupSelections);
418
+ groupSelectionsRef.current = groupSelections;
419
+ const hoverSelectionRef = useRef(hoverSelection);
420
+ hoverSelectionRef.current = hoverSelection;
148
421
  const overlayRectRef = useRef(overlayRect);
149
422
  overlayRectRef.current = overlayRect;
150
- const onMoveCommitRef = useRef(onMoveCommit);
151
- onMoveCommitRef.current = onMoveCommit;
152
- const onResizeCommitRef = useRef(onResizeCommit);
153
- onResizeCommitRef.current = onResizeCommit;
423
+ const hoverRectRef = useRef(hoverRect);
424
+ hoverRectRef.current = hoverRect;
425
+ const groupOverlayItemsRef = useRef(groupOverlayItems);
426
+ groupOverlayItemsRef.current = groupOverlayItems;
427
+ const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
428
+ onPathOffsetCommitRef.current = onPathOffsetCommit;
429
+ const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
430
+ onGroupPathOffsetCommitRef.current = onGroupPathOffsetCommit;
431
+ const onBoxSizeCommitRef = useRef(onBoxSizeCommit);
432
+ onBoxSizeCommitRef.current = onBoxSizeCommit;
433
+ const onRotationCommitRef = useRef(onRotationCommit);
434
+ onRotationCommitRef.current = onRotationCommit;
154
435
  const onBlockedMoveRef = useRef(onBlockedMove);
155
436
  onBlockedMoveRef.current = onBlockedMove;
437
+ const onManualDragStartRef = useRef(onManualDragStart);
438
+ onManualDragStartRef.current = onManualDragStart;
439
+ const onCanvasPointerMoveRef = useRef(onCanvasPointerMove);
440
+ onCanvasPointerMoveRef.current = onCanvasPointerMove;
441
+ const onCanvasPointerLeaveRef = useRef(onCanvasPointerLeave);
442
+ onCanvasPointerLeaveRef.current = onCanvasPointerLeave;
443
+ const onSelectionChangeRef = useRef(onSelectionChange);
444
+ onSelectionChangeRef.current = onSelectionChange;
156
445
 
157
446
  useMountEffect(() => {
158
447
  let frame = 0;
@@ -166,9 +455,29 @@ export const DomEditOverlay = memo(function DomEditOverlay({
166
455
  overlayRectRef.current = next;
167
456
  setOverlayRect(next);
168
457
  };
169
- const resolveElement = (doc: Document, sel: DomEditSelection) => {
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) => {
170
479
  const key = selectionCacheKey(sel);
171
- const cached = resolvedElementRef.current;
480
+ const cached = cacheRef.current;
172
481
  if (
173
482
  cached?.key === key &&
174
483
  cached.element.isConnected &&
@@ -177,8 +486,21 @@ export const DomEditOverlay = memo(function DomEditOverlay({
177
486
  return cached.element;
178
487
  }
179
488
 
180
- const next = findElementForSelection(doc, sel, sel.sourceFile);
181
- resolvedElementRef.current = next ? { key, element: next } : null;
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
+ }
182
504
  return next;
183
505
  };
184
506
 
@@ -189,23 +511,80 @@ export const DomEditOverlay = memo(function DomEditOverlay({
189
511
  const sel = selectionRef.current;
190
512
  const iframe = iframeRef.current;
191
513
  const overlayEl = overlayRef.current;
192
- if (!sel || !iframe || !overlayEl) {
514
+ if (!iframe || !overlayEl) {
193
515
  resolvedElementRef.current = null;
516
+ resolvedHoverElementRef.current = null;
517
+ resolvedGroupElementRef.current.clear();
194
518
  clearOverlayRect();
519
+ clearHoverRect();
520
+ clearGroupOverlayItems();
195
521
  return;
196
522
  }
197
523
 
198
524
  const doc = iframe.contentDocument;
199
- if (!doc) return;
525
+ if (!doc) {
526
+ resolvedElementRef.current = null;
527
+ resolvedHoverElementRef.current = null;
528
+ resolvedGroupElementRef.current.clear();
529
+ clearOverlayRect();
530
+ clearHoverRect();
531
+ clearGroupOverlayItems();
532
+ return;
533
+ }
200
534
 
201
- const el = resolveElement(doc, sel);
202
- if (!el) {
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;
203
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();
204
578
  return;
205
579
  }
206
580
 
207
- const next = toOverlayRect(overlayEl, iframe, el);
208
- setNextOverlayRect(next);
581
+ const hoverEl = resolveElement(doc, hoverSel, resolvedHoverElementRef);
582
+ if (!hoverEl) {
583
+ clearHoverRect();
584
+ return;
585
+ }
586
+
587
+ setNextHoverRect(toOverlayRect(overlayEl, iframe, hoverEl));
209
588
  };
210
589
 
211
590
  frame = requestAnimationFrame(update);
@@ -218,50 +597,188 @@ export const DomEditOverlay = memo(function DomEditOverlay({
218
597
  selection.selectorIndex ?? 0
219
598
  }`;
220
599
  }, [selection]);
600
+ const groupBounds = useMemo(
601
+ () => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)),
602
+ [groupOverlayItems],
603
+ );
604
+ const hasGroupSelection = groupSelections.length > 1;
605
+ const groupCanMove =
606
+ hasGroupSelection &&
607
+ groupOverlayItems.length > 1 &&
608
+ groupOverlayItems.every((item) => item.selection.capabilities.canApplyManualOffset);
221
609
 
222
- const startGesture = (kind: GestureKind, e: React.PointerEvent) => {
223
- const sel = selectionRef.current;
224
- const rect = overlayRectRef.current;
225
- const box = boxRef.current;
226
- if (!sel || !rect || !box) return;
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
+ };
227
636
 
228
- const left = Number.parseFloat(sel.computedStyles.left ?? "");
229
- const top = Number.parseFloat(sel.computedStyles.top ?? "");
230
- const width = Number.parseFloat(sel.computedStyles.width ?? "");
231
- const height = Number.parseFloat(sel.computedStyles.height ?? "");
232
- if (!Number.isFinite(left) || !Number.isFinite(top)) return;
233
- if (kind === "resize" && !Number.isFinite(width) && !Number.isFinite(height)) return;
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
+ }
234
672
 
235
673
  e.preventDefault();
236
674
  e.stopPropagation();
237
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
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);
238
743
 
239
744
  rafPausedRef.current = true;
240
745
 
241
746
  gestureRef.current = {
242
747
  kind,
748
+ mode,
749
+ selection: sel,
243
750
  startX: e.clientX,
244
751
  startY: e.clientY,
245
- initialStyleLeft: sel.element.style.left,
246
- initialStyleTop: sel.element.style.top,
752
+ centerX,
753
+ centerY,
754
+ initialPathOffset,
755
+ initialRotation: captureStudioRotation(sel.element),
756
+ initialBoxSize: captureStudioBoxSize(sel.element),
757
+ pathOffsetMember,
247
758
  originLeft: rect.left,
248
759
  originTop: rect.top,
249
760
  originWidth: rect.width,
250
761
  originHeight: rect.height,
251
- actualLeft: left,
252
- actualTop: top,
253
- actualWidth: Number.isFinite(width) ? width : 0,
254
- actualHeight: Number.isFinite(height) ? height : 0,
255
- scaleX: rect.scaleX,
256
- scaleY: rect.scaleY,
762
+ actualWidth,
763
+ actualHeight,
764
+ actualRotation: rotation.angle,
765
+ editScaleX: rect.editScaleX,
766
+ editScaleY: rect.editScaleY,
767
+ manualEditDragToken,
257
768
  };
769
+ return true;
258
770
  };
259
771
 
260
- const onPointerMove = (e: React.PointerEvent) => {
772
+ const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
261
773
  const g = gestureRef.current;
262
- const sel = selectionRef.current;
774
+ const groupG = groupGestureRef.current;
775
+ const sel = g?.selection ?? selectionRef.current;
263
776
  const box = boxRef.current;
264
777
  const blockedMove = blockedMoveRef.current;
778
+ if (!blockedMove && !g && !groupG) {
779
+ onCanvasPointerMoveRef.current(e, { preferClipAncestor: false });
780
+ }
781
+
265
782
  if (blockedMove && sel) {
266
783
  const dx = e.clientX - blockedMove.startX;
267
784
  const dy = e.clientY - blockedMove.startY;
@@ -273,33 +790,139 @@ export const DomEditOverlay = memo(function DomEditOverlay({
273
790
  return;
274
791
  }
275
792
 
276
- if (!g || !sel || !box) return;
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;
277
813
 
278
814
  const dx = e.clientX - g.startX;
279
815
  const dy = e.clientY - g.startY;
280
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
+
281
832
  if (g.kind === "drag") {
282
833
  const nextBoxLeft = g.originLeft + dx;
283
834
  const nextBoxTop = g.originTop + dy;
284
- box.style.left = `${nextBoxLeft}px`;
285
- box.style.top = `${nextBoxTop}px`;
286
- sel.element.style.left = `${Math.round(g.actualLeft + dx / g.scaleX)}px`;
287
- sel.element.style.top = `${Math.round(g.actualTop + dy / g.scaleY)}px`;
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);
288
848
  } else {
289
- const newW = Math.max(20, g.originWidth + dx);
290
- const newH = Math.max(20, g.originHeight + dy);
291
- box.style.width = `${newW}px`;
292
- box.style.height = `${newH}px`;
293
- sel.element.style.width = `${Math.round(g.actualWidth + dx / g.scaleX)}px`;
294
- sel.element.style.height = `${Math.round(g.actualHeight + dy / g.scaleY)}px`;
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);
295
872
  }
296
873
  };
297
874
 
298
875
  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
299
876
  const g = gestureRef.current;
300
- const sel = selectionRef.current;
877
+ const groupG = groupGestureRef.current;
878
+ const sel = g?.selection ?? selectionRef.current;
301
879
  const box = boxRef.current;
302
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
+
303
926
  if (!g || !sel) {
304
927
  gestureRef.current = null;
305
928
  rafPausedRef.current = false;
@@ -311,12 +934,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
311
934
 
312
935
  const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
313
936
  if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
314
- restoreInlineStyle(sel.element, "left", g.initialStyleLeft);
315
- restoreInlineStyle(sel.element, "top", g.initialStyleTop);
937
+ restoreStudioPathOffset(sel.element, g.initialPathOffset);
938
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
316
939
  if (box) {
317
940
  box.style.left = `${g.originLeft}px`;
318
941
  box.style.top = `${g.originTop}px`;
319
942
  }
943
+ restoreGestureOverlayRect(g);
320
944
  suppressNextBoxClickRef.current = true;
321
945
  onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
322
946
  preferClipAncestor: false,
@@ -324,26 +948,92 @@ export const DomEditOverlay = memo(function DomEditOverlay({
324
948
  return;
325
949
  }
326
950
 
327
- if (g.kind === "drag") {
328
- const finalLeft = Number.parseFloat(sel.element.style.left) || g.actualLeft;
329
- const finalTop = Number.parseFloat(sel.element.style.top) || g.actualTop;
330
- void Promise.resolve(onMoveCommitRef.current(sel, { left: finalLeft, top: finalTop })).catch(
331
- () => {
332
- sel.element.style.left = `${Math.round(g.actualLeft)}px`;
333
- sel.element.style.top = `${Math.round(g.actualTop)}px`;
334
- },
335
- );
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
+ });
336
1022
  } else {
337
- const finalW = Number.parseFloat(sel.element.style.width) || g.actualWidth;
338
- const finalH = Number.parseFloat(sel.element.style.height) || g.actualHeight;
339
- void Promise.resolve(onResizeCommitRef.current(sel, { width: finalW, height: finalH })).catch(
340
- () => {
341
- if (g.actualWidth > 0) sel.element.style.width = `${Math.round(g.actualWidth)}px`;
342
- else sel.element.style.removeProperty("width");
343
- if (g.actualHeight > 0) sel.element.style.height = `${Math.round(g.actualHeight)}px`;
344
- else sel.element.style.removeProperty("height");
345
- },
346
- );
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
+ });
347
1037
  }
348
1038
  };
349
1039
 
@@ -352,22 +1042,78 @@ export const DomEditOverlay = memo(function DomEditOverlay({
352
1042
  // box stops propagation for drag gestures, but clicks on the transparent overlay
353
1043
  // area outside the box pass through to the iframe pick logic.
354
1044
  const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
1045
+ if (suppressNextOverlayMouseDownRef.current) {
1046
+ suppressNextOverlayMouseDownRef.current = false;
1047
+ suppressNextBoxMouseDownRef.current = false;
1048
+ suppressNextBoxClickRef.current = false;
1049
+ event.preventDefault();
1050
+ event.stopPropagation();
1051
+ return;
1052
+ }
355
1053
  const target = event.target as HTMLElement | null;
356
1054
  if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
357
1055
  onCanvasMouseDown(event, { preferClipAncestor: false });
1056
+ if (event.shiftKey) {
1057
+ suppressNextBoxMouseDownRef.current = true;
1058
+ suppressNextBoxClickRef.current = true;
1059
+ }
358
1060
  };
359
1061
 
360
- const handleOverlayDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
1062
+ const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
1063
+ if (!allowCanvasMovement || event.button !== 0) return;
1064
+ if (event.shiftKey) {
1065
+ const candidate =
1066
+ onCanvasPointerMoveRef.current(event, {
1067
+ preferClipAncestor: false,
1068
+ }) ?? hoverSelectionRef.current;
1069
+ if (!candidate) return;
1070
+
1071
+ event.preventDefault();
1072
+ event.stopPropagation();
1073
+ suppressNextOverlayMouseDownRef.current = true;
1074
+ suppressNextBoxMouseDownRef.current = true;
1075
+ suppressNextBoxClickRef.current = true;
1076
+ onSelectionChangeRef.current(candidate, { additive: true });
1077
+ return;
1078
+ }
1079
+
361
1080
  const target = event.target as HTMLElement | null;
362
1081
  if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
363
- onCanvasDoubleClick(event);
1082
+
1083
+ const hoverCandidate = onCanvasPointerMoveRef.current(event, {
1084
+ preferClipAncestor: false,
1085
+ });
1086
+ const candidate = hoverCandidate ?? hoverSelectionRef.current;
1087
+ if (!candidate?.capabilities.canApplyManualOffset) return;
1088
+
1089
+ const overlayEl = overlayRef.current;
1090
+ const iframe = iframeRef.current;
1091
+ const candidateRect =
1092
+ overlayEl && iframe ? toOverlayRect(overlayEl, iframe, candidate.element) : null;
1093
+ if (!candidateRect) return;
1094
+
1095
+ suppressNextOverlayMouseDownRef.current = true;
1096
+ selectionRef.current = candidate;
1097
+ overlayRectRef.current = candidateRect;
1098
+ hoverRectRef.current = null;
1099
+ setOverlayRect(candidateRect);
1100
+ setHoverRect(null);
1101
+ const didStartGesture = startGesture("drag", event, {
1102
+ selection: candidate,
1103
+ rect: candidateRect,
1104
+ });
1105
+ if (!didStartGesture) {
1106
+ suppressNextOverlayMouseDownRef.current = false;
1107
+ return;
1108
+ }
1109
+ onSelectionChangeRef.current(candidate);
364
1110
  };
365
1111
 
366
1112
  // Click on the selection box itself → re-pick the element under the pointer.
367
1113
  // This lets you click a child element even when a parent is selected, because
368
1114
  // the click coordinates are forwarded to the iframe's element picker.
369
1115
  const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
370
- if (gestureRef.current) return;
1116
+ if (gestureRef.current || groupGestureRef.current) return;
371
1117
  if (suppressNextBoxClickRef.current) {
372
1118
  suppressNextBoxClickRef.current = false;
373
1119
  event.stopPropagation();
@@ -377,24 +1123,124 @@ export const DomEditOverlay = memo(function DomEditOverlay({
377
1123
  };
378
1124
 
379
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
+ }
380
1144
  blockedMoveRef.current = null;
1145
+ groupGestureRef.current = null;
381
1146
  gestureRef.current = null;
382
1147
  rafPausedRef.current = false;
383
1148
  };
384
1149
 
385
1150
  return (
386
1151
  <div
387
- key={selectionKey}
388
1152
  ref={overlayRef}
389
- className="absolute inset-0 z-10 pointer-events-auto"
1153
+ className="absolute inset-0 z-10 pointer-events-auto outline-none"
1154
+ tabIndex={-1}
1155
+ aria-label="Composition canvas"
1156
+ onPointerDownCapture={(event) => focusDomEditOverlayElement(event.currentTarget)}
1157
+ onPointerDown={handleOverlayPointerDown}
390
1158
  onMouseDown={handleOverlayMouseDown}
391
- onDoubleClick={handleOverlayDoubleClick}
392
1159
  onPointerMove={onPointerMove}
1160
+ onPointerLeave={() => onCanvasPointerLeaveRef.current()}
393
1161
  onPointerUp={onPointerUp}
394
1162
  onPointerCancel={clearPointerState}
395
1163
  >
396
- {selection && overlayRect && (
1164
+ {hoverSelection && hoverRect && (
1165
+ <div
1166
+ aria-hidden="true"
1167
+ data-dom-edit-hover-box="true"
1168
+ className="pointer-events-none absolute rounded-xl border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
1169
+ style={{
1170
+ left: hoverRect.left,
1171
+ top: hoverRect.top,
1172
+ width: hoverRect.width,
1173
+ height: hoverRect.height,
1174
+ }}
1175
+ />
1176
+ )}
1177
+ {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && (
1178
+ <>
1179
+ {groupOverlayItems.map((item) => (
1180
+ <div
1181
+ key={item.key}
1182
+ aria-hidden="true"
1183
+ className="pointer-events-none absolute rounded-xl border border-studio-accent/70 bg-studio-accent/[0.03]"
1184
+ style={{
1185
+ left: item.rect.left,
1186
+ top: item.rect.top,
1187
+ width: item.rect.width,
1188
+ height: item.rect.height,
1189
+ }}
1190
+ />
1191
+ ))}
1192
+ <div
1193
+ data-dom-edit-selection-box="true"
1194
+ className="pointer-events-auto absolute rounded-xl border border-studio-accent bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
1195
+ style={{
1196
+ left: groupBounds.left,
1197
+ top: groupBounds.top,
1198
+ width: groupBounds.width,
1199
+ height: groupBounds.height,
1200
+ cursor: allowCanvasMovement && groupCanMove ? "move" : "default",
1201
+ }}
1202
+ onPointerDown={(e) => {
1203
+ if (!allowCanvasMovement) return;
1204
+ if (e.shiftKey) return;
1205
+ startGroupDrag(e);
1206
+ }}
1207
+ onMouseDown={(e) => {
1208
+ if (!suppressNextBoxMouseDownRef.current) return;
1209
+ suppressNextBoxMouseDownRef.current = false;
1210
+ e.preventDefault();
1211
+ e.stopPropagation();
1212
+ }}
1213
+ onClick={handleBoxClick}
1214
+ />
1215
+ </>
1216
+ )}
1217
+ {!hasGroupSelection && selection && overlayRect && (
397
1218
  <>
1219
+ {allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
1220
+ <div
1221
+ className="pointer-events-none absolute"
1222
+ style={{
1223
+ left: overlayRect.left + overlayRect.width / 2,
1224
+ top: overlayRect.top - 34,
1225
+ width: 28,
1226
+ height: 34,
1227
+ transform: "translateX(-50%)",
1228
+ }}
1229
+ >
1230
+ <div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
1231
+ <button
1232
+ type="button"
1233
+ className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
1234
+ style={{ cursor: "grab", touchAction: "none" }}
1235
+ title="Rotate"
1236
+ aria-label="Rotate selection"
1237
+ onPointerDown={(e) => {
1238
+ e.stopPropagation();
1239
+ startGesture("rotate", e);
1240
+ }}
1241
+ />
1242
+ </div>
1243
+ )}
398
1244
  <div
399
1245
  key={selectionKey}
400
1246
  ref={boxRef}
@@ -405,11 +1251,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
405
1251
  top: overlayRect.top,
406
1252
  width: overlayRect.width,
407
1253
  height: overlayRect.height,
408
- cursor: allowCanvasMovement && selection.capabilities.canMove ? "move" : "default",
1254
+ cursor:
1255
+ allowCanvasMovement && selection.capabilities.canApplyManualOffset
1256
+ ? "move"
1257
+ : "default",
409
1258
  }}
410
1259
  onPointerDown={(e) => {
411
1260
  if (!allowCanvasMovement) return;
412
- if (selection.capabilities.canMove) {
1261
+ if (e.shiftKey) return;
1262
+ if (selection.capabilities.canApplyManualOffset) {
413
1263
  startGesture("drag", e);
414
1264
  return;
415
1265
  }
@@ -423,11 +1273,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
423
1273
  notified: false,
424
1274
  };
425
1275
  }}
1276
+ onMouseDown={(e) => {
1277
+ if (!suppressNextBoxMouseDownRef.current) return;
1278
+ suppressNextBoxMouseDownRef.current = false;
1279
+ e.preventDefault();
1280
+ e.stopPropagation();
1281
+ }}
426
1282
  onClick={handleBoxClick}
427
- onDoubleClick={onSelectedDoubleClick}
428
1283
  >
429
1284
  {/* Resize handle — bottom-right corner */}
430
- {allowCanvasMovement && selection.capabilities.canResize && (
1285
+ {allowCanvasMovement && selection.capabilities.canApplyManualSize && (
431
1286
  <div
432
1287
  className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
433
1288
  style={{ cursor: "se-resize", touchAction: "none" }}