@hyperframes/studio 0.7.5 → 0.7.7

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 (77) hide show
  1. package/dist/assets/{index-BwFzbjZQ.js → index-B2YXvFxf.js} +1 -1
  2. package/dist/assets/index-BSkUuN8g.css +1 -0
  3. package/dist/assets/index-BeRh2hMe.js +375 -0
  4. package/dist/assets/{index-C5NAfiPa.js → index-BoASKOeE.js} +1 -1
  5. package/dist/chunk-KZXYQYIU.js +876 -0
  6. package/dist/chunk-KZXYQYIU.js.map +1 -0
  7. package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
  8. package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +3326 -2939
  12. package/dist/index.js.map +1 -1
  13. package/package.json +5 -5
  14. package/src/captions/hooks/useCaptionSync.ts +6 -1
  15. package/src/components/StudioPreviewArea.tsx +10 -3
  16. package/src/components/StudioRightPanel.tsx +4 -0
  17. package/src/components/editor/AnimationCard.tsx +77 -250
  18. package/src/components/editor/AnimationCardParts.tsx +220 -0
  19. package/src/components/editor/BlockParamsPanel.tsx +4 -8
  20. package/src/components/editor/DomEditOverlay.tsx +132 -48
  21. package/src/components/editor/EaseCurveSection.tsx +177 -101
  22. package/src/components/editor/GsapAnimationSection.tsx +4 -0
  23. package/src/components/editor/KeyframeEaseList.tsx +127 -0
  24. package/src/components/editor/OffCanvasIndicators.tsx +111 -0
  25. package/src/components/editor/PropertyPanel.tsx +63 -56
  26. package/src/components/editor/gsapAnimationCallbacks.ts +3 -0
  27. package/src/components/editor/gsapAnimationConstants.ts +11 -32
  28. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  29. package/src/components/editor/manualOffsetDrag.ts +8 -0
  30. package/src/components/editor/marqueeCommit.ts +168 -0
  31. package/src/components/editor/propertyPanelHelpers.ts +7 -0
  32. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  33. package/src/components/editor/snapTargetCollection.ts +0 -5
  34. package/src/components/editor/useMotionPathData.ts +2 -1
  35. package/src/components/panels/SlideshowPanel.tsx +0 -1
  36. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  37. package/src/components/sidebar/AssetsTab.tsx +542 -254
  38. package/src/components/sidebar/assetHelpers.ts +40 -0
  39. package/src/contexts/DomEditContext.tsx +12 -0
  40. package/src/hooks/gsapDragCommit.ts +101 -0
  41. package/src/hooks/gsapDragPositionCommit.ts +1 -2
  42. package/src/hooks/gsapRuntimeBridge.ts +28 -2
  43. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  44. package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
  45. package/src/hooks/gsapRuntimeReaders.ts +1 -6
  46. package/src/hooks/useDomEditCommits.ts +0 -4
  47. package/src/hooks/useDomEditPreviewSync.ts +5 -0
  48. package/src/hooks/useDomEditSession.ts +43 -0
  49. package/src/hooks/useDomEditTextCommits.ts +3 -12
  50. package/src/hooks/useDomEditWiring.ts +3 -0
  51. package/src/hooks/useDomSelection.ts +46 -0
  52. package/src/hooks/useGestureCommit.ts +26 -7
  53. package/src/hooks/useGestureRecording.ts +2 -16
  54. package/src/hooks/useGsapAnimationOps.ts +2 -2
  55. package/src/hooks/useGsapAwareEditing.ts +0 -4
  56. package/src/hooks/useGsapScriptCommits.ts +0 -12
  57. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  58. package/src/hooks/useGsapTweenCache.ts +151 -42
  59. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  60. package/src/hooks/useRazorSplit.ts +0 -3
  61. package/src/player/components/Player.tsx +0 -5
  62. package/src/player/components/timelineIcons.tsx +2 -1
  63. package/src/player/hooks/useTimelinePlayer.ts +4 -14
  64. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
  65. package/src/player/lib/timelineIframeHelpers.ts +2 -6
  66. package/src/telemetry/client.ts +2 -0
  67. package/src/utils/gestureSmoother.test.ts +48 -0
  68. package/src/utils/gestureSmoother.ts +46 -0
  69. package/src/utils/marqueeGeometry.test.ts +123 -0
  70. package/src/utils/marqueeGeometry.ts +172 -0
  71. package/src/utils/optimisticUpdate.ts +1 -2
  72. package/src/utils/sourcePatcher.ts +0 -10
  73. package/src/utils/velocityEaseFitter.test.ts +58 -0
  74. package/src/utils/velocityEaseFitter.ts +121 -0
  75. package/dist/assets/index-D_JGXmfx.js +0 -374
  76. package/dist/assets/index-DzWIinxk.css +0 -1
  77. package/src/utils/editDebugLog.ts +0 -16
@@ -0,0 +1,220 @@
1
+ import { MetricField } from "./propertyPanelPrimitives";
2
+ import {
3
+ PERCENT_PROPS,
4
+ PROP_CONSTRAINTS,
5
+ PROP_LABELS,
6
+ PROP_TOOLTIPS,
7
+ PROP_UNITS,
8
+ clampPropertyValue,
9
+ } from "./gsapAnimationConstants";
10
+ import { P } from "./panelTokens";
11
+
12
+ export const BOOLEAN_PROPS = new Set(["visibility"]);
13
+ const STRING_PROPS = new Set(["filter", "clipPath"]);
14
+ const FILTER_PRESETS = [
15
+ { label: "Blur", value: "blur(4px)" },
16
+ { label: "Bright", value: "brightness(1.5)" },
17
+ { label: "Gray", value: "grayscale(1)" },
18
+ { label: "None", value: "none" },
19
+ ];
20
+ const CLIP_PATH_PRESETS = [
21
+ { label: "Circle", value: "circle(50% at 50% 50%)" },
22
+ { label: "Inset", value: "inset(10%)" },
23
+ { label: "None", value: "none" },
24
+ ];
25
+
26
+ function isPercentProp(prop: string): boolean {
27
+ return PERCENT_PROPS.has(prop);
28
+ }
29
+
30
+ function displayValue(prop: string, val: number | string): string {
31
+ if (isPercentProp(prop)) return String(Math.round(Math.max(0, Math.min(1, Number(val))) * 100));
32
+ return String(val);
33
+ }
34
+
35
+ function adjustedValue(prop: string, raw: string): string {
36
+ if (isPercentProp(prop)) return String(clampPropertyValue(prop, Number(raw) / 100));
37
+ const num = Number(raw);
38
+ if (!Number.isNaN(num) && PROP_CONSTRAINTS[prop]) {
39
+ return String(clampPropertyValue(prop, num));
40
+ }
41
+ return raw;
42
+ }
43
+
44
+ function RemoveButton({ onClick, title }: { onClick: () => void; title: string }) {
45
+ return (
46
+ <button
47
+ type="button"
48
+ onClick={onClick}
49
+ className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
50
+ title={title}
51
+ >
52
+ <svg
53
+ width="12"
54
+ height="12"
55
+ viewBox="0 0 12 12"
56
+ fill="none"
57
+ stroke="currentColor"
58
+ strokeWidth="1.5"
59
+ >
60
+ <path d="M3 3l6 6M9 3l-6 6" />
61
+ </svg>
62
+ </button>
63
+ );
64
+ }
65
+
66
+ // fallow-ignore-next-line complexity
67
+ export function PropertyRow({
68
+ prop,
69
+ val,
70
+ onCommit,
71
+ onRemove,
72
+ removeTitle,
73
+ }: {
74
+ prop: string;
75
+ val: number | string;
76
+ onCommit: (adjusted: string) => void;
77
+ onRemove: () => void;
78
+ removeTitle: string;
79
+ }) {
80
+ if (BOOLEAN_PROPS.has(prop)) {
81
+ const isVisible = val === "visible" || val === 1;
82
+ return (
83
+ <div className="flex items-center gap-1">
84
+ <div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
85
+ <span className="flex-1 text-[11px] font-medium text-neutral-500">
86
+ {PROP_LABELS[prop] ?? prop}
87
+ </span>
88
+ <button
89
+ type="button"
90
+ onClick={() => onCommit(isVisible ? "hidden" : "visible")}
91
+ className="flex-shrink-0 rounded-full transition-all duration-150 relative"
92
+ style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
93
+ title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
94
+ >
95
+ <span
96
+ className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
97
+ style={{
98
+ width: 12,
99
+ height: 12,
100
+ background: isVisible ? P.white : P.textMuted,
101
+ transform: isVisible ? "translateX(14px)" : "translateX(2px)",
102
+ }}
103
+ />
104
+ </button>
105
+ </div>
106
+ <RemoveButton onClick={onRemove} title={removeTitle} />
107
+ </div>
108
+ );
109
+ }
110
+ if (STRING_PROPS.has(prop)) {
111
+ const presets =
112
+ prop === "filter" ? FILTER_PRESETS : prop === "clipPath" ? CLIP_PATH_PRESETS : [];
113
+ return (
114
+ <div className="flex flex-col gap-1">
115
+ <div className="flex items-center gap-1">
116
+ <div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
117
+ <span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">
118
+ {PROP_LABELS[prop] ?? prop}
119
+ </span>
120
+ <input
121
+ type="text"
122
+ defaultValue={String(val)}
123
+ className="flex-1 bg-transparent text-[11px] text-neutral-200 outline-none"
124
+ onBlur={(e) => onCommit(e.currentTarget.value)}
125
+ onKeyDown={(e) => {
126
+ if (e.key === "Enter") e.currentTarget.blur();
127
+ }}
128
+ />
129
+ </div>
130
+ <RemoveButton onClick={onRemove} title={removeTitle} />
131
+ </div>
132
+ {presets.length > 0 && (
133
+ <div className="flex gap-1 pl-1">
134
+ {presets.map((p) => (
135
+ <button
136
+ key={p.value}
137
+ type="button"
138
+ onClick={() => onCommit(p.value)}
139
+ className="px-1.5 py-0.5 rounded text-[9px] font-medium text-neutral-500 bg-neutral-800/50 hover:bg-neutral-800 hover:text-neutral-300 transition-colors"
140
+ >
141
+ {p.label}
142
+ </button>
143
+ ))}
144
+ </div>
145
+ )}
146
+ </div>
147
+ );
148
+ }
149
+ return (
150
+ <div className="flex items-center gap-1">
151
+ <div className="min-w-0 flex-1">
152
+ <MetricField
153
+ label={PROP_LABELS[prop] ?? prop}
154
+ value={displayValue(prop, val)}
155
+ suffix={PROP_UNITS[prop]}
156
+ tooltip={PROP_TOOLTIPS[prop]}
157
+ scrub
158
+ liveCommit
159
+ onCommit={(raw) => onCommit(adjustedValue(prop, raw))}
160
+ />
161
+ </div>
162
+ <RemoveButton onClick={onRemove} title={removeTitle} />
163
+ </div>
164
+ );
165
+ }
166
+
167
+ export function AddPropertyTrigger({
168
+ adding,
169
+ available,
170
+ addLabel,
171
+ addTitle,
172
+ onAdd,
173
+ onOpen,
174
+ onClose,
175
+ buttonClassName,
176
+ }: {
177
+ adding: boolean;
178
+ available: string[];
179
+ addLabel: string;
180
+ addTitle: string;
181
+ onAdd: (prop: string) => void;
182
+ onOpen: () => void;
183
+ onClose: () => void;
184
+ buttonClassName: string;
185
+ }) {
186
+ if (adding && available.length > 0) {
187
+ return (
188
+ <select
189
+ autoFocus
190
+ className="min-w-0 rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-[11px] text-neutral-100 outline-none"
191
+ defaultValue=""
192
+ onChange={(e) => {
193
+ if (e.target.value) onAdd(e.target.value);
194
+ onClose();
195
+ }}
196
+ onBlur={onClose}
197
+ >
198
+ <option value="" disabled>
199
+ Choose property…
200
+ </option>
201
+ {available.map((p) => (
202
+ <option key={p} value={p}>
203
+ {PROP_LABELS[p] ?? p}
204
+ </option>
205
+ ))}
206
+ </select>
207
+ );
208
+ }
209
+ if (available.length === 0) return null;
210
+ return (
211
+ <button type="button" onClick={onOpen} className={buttonClassName} title={addTitle}>
212
+ {addLabel}
213
+ </button>
214
+ );
215
+ }
216
+
217
+ export function parseNumericOrString(raw: string): number | string {
218
+ const num = Number(raw);
219
+ return Number.isFinite(num) ? num : raw;
220
+ }
@@ -12,7 +12,7 @@ interface BlockParamsPanelProps {
12
12
  export const BlockParamsPanel = memo(function BlockParamsPanel({
13
13
  blockTitle,
14
14
  params,
15
- compositionPath,
15
+ compositionPath: _compositionPath,
16
16
  onClose,
17
17
  }: BlockParamsPanelProps) {
18
18
  const [values, setValues] = useState<Record<string, string>>(() => {
@@ -23,13 +23,9 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
23
23
  return initial;
24
24
  });
25
25
 
26
- const handleChange = useCallback(
27
- (key: string, value: string) => {
28
- setValues((prev) => ({ ...prev, [key]: value }));
29
- console.log(`[BlockParams] ${compositionPath} ${key}: ${value}`);
30
- },
31
- [compositionPath],
32
- );
26
+ const handleChange = useCallback((key: string, value: string) => {
27
+ setValues((prev) => ({ ...prev, [key]: value }));
28
+ }, []);
33
29
 
34
30
  return (
35
31
  <div className="flex flex-col h-full">
@@ -1,7 +1,10 @@
1
- import { memo, useMemo, useRef, useState, type RefObject } from "react";
1
+ import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { type DomEditSelection } from "./domEditing";
4
- import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
4
+ import { useMarqueeGestures } from "./marqueeCommit";
5
+ import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
6
+ import { collectDomEditLayerItems } from "./domEditingLayers";
7
+ import { isElementComputedVisible } from "./domEditingElement";
5
8
  import {
6
9
  type BlockedMoveState,
7
10
  type DomEditGroupPathOffsetCommit,
@@ -11,6 +14,7 @@ import {
11
14
  focusDomEditOverlayElement,
12
15
  } from "./domEditOverlayGestures";
13
16
  import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
17
+ import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
14
18
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
15
19
  import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
16
20
  import { GridOverlay } from "./GridOverlay";
@@ -67,8 +71,10 @@ interface DomEditOverlayProps {
67
71
  gridSpacing?: number;
68
72
  recordingState?: GestureRecordingState;
69
73
  onToggleRecording?: () => void;
74
+ onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
70
75
  }
71
76
 
77
+ // fallow-ignore-next-line complexity
72
78
  export const DomEditOverlay = memo(function DomEditOverlay({
73
79
  iframeRef,
74
80
  activeCompositionPath,
@@ -88,13 +94,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
88
94
  onGroupPathOffsetCommit,
89
95
  onBoxSizeCommit,
90
96
  onRotationCommit,
97
+ onMarqueeSelect,
91
98
  }: DomEditOverlayProps) {
92
99
  const overlayRef = useRef<HTMLDivElement | null>(null);
93
100
  const boxRef = useRef<HTMLDivElement | null>(null);
101
+ const onMarqueeSelectRef = useRef(onMarqueeSelect);
102
+ onMarqueeSelectRef.current = onMarqueeSelect;
94
103
 
95
104
  const selectionShapeStyles = (() => {
96
105
  const fallback = {
97
- borderRadius: 4 as string | number,
106
+ borderRadius: 8 as string | number,
98
107
  clipPath: undefined as string | undefined,
99
108
  };
100
109
  if (!selection?.element) return fallback;
@@ -213,6 +222,50 @@ export const DomEditOverlay = memo(function DomEditOverlay({
213
222
  return () => cancelAnimationFrame(frame);
214
223
  });
215
224
 
225
+ // Off-canvas element indicators — dashed outlines for elements positioned
226
+ // outside the composition bounds so users can find them.
227
+ const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
228
+ const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
229
+ useEffect(() => {
230
+ const iframe = iframeRef.current;
231
+ const overlay = overlayRef.current;
232
+ if (!iframe || !overlay || compRect.width <= 0) {
233
+ setOffCanvasRects([]);
234
+ return;
235
+ }
236
+ const doc = iframe.contentDocument;
237
+ if (!doc) return;
238
+ const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
239
+ const acp = activeCompositionPath ?? "index.html";
240
+ const items = collectDomEditLayerItems(root, {
241
+ activeCompositionPath: acp,
242
+ isMasterView: !acp || acp === "index.html",
243
+ });
244
+ const rects: typeof offCanvasRects = [];
245
+ const elMap = new Map<string, HTMLElement>();
246
+ for (const item of items) {
247
+ if (!isElementComputedVisible(item.element)) continue;
248
+ const r = toOverlayRect(overlay, iframe, item.element);
249
+ if (!r) continue;
250
+ // Any edge crossing the composition border → gray-zone indicator (the
251
+ // in-canvas portion is clipped away below, so only the sliver shows).
252
+ const extendsOutsideComp =
253
+ r.left < compRect.left ||
254
+ r.left + r.width > compRect.left + compRect.width ||
255
+ r.top < compRect.top ||
256
+ r.top + r.height > compRect.top + compRect.height;
257
+ if (extendsOutsideComp) {
258
+ rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
259
+ elMap.set(item.key, item.element);
260
+ }
261
+ }
262
+ offCanvasElementsRef.current = elMap;
263
+ setOffCanvasRects(rects);
264
+ // Positions depend on layout, not selection — the selected-element
265
+ // suppression is a render-time filter, so selection/groupSelections stay
266
+ // out of the deps to avoid re-walking geometry on each selection change.
267
+ }, [iframeRef, compRect, activeCompositionPath]);
268
+
216
269
  const gestures = createDomEditOverlayGestureHandlers({
217
270
  overlayRef,
218
271
  iframeRef,
@@ -238,6 +291,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
238
291
  snapGuidesRef,
239
292
  });
240
293
 
294
+ const marquee = useMarqueeGestures({
295
+ iframeRef,
296
+ overlayRef,
297
+ activeCompositionPathRef,
298
+ onMarqueeSelectRef,
299
+ selectionRef,
300
+ gestures,
301
+ });
302
+
241
303
  const selectionKey = useMemo(() => {
242
304
  if (!selection) return "none";
243
305
  return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
@@ -265,22 +327,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
265
327
  }
266
328
  const target = event.target as HTMLElement | null;
267
329
  if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
268
- // Don't re-resolve selection when clicking outside the composition bounds
269
- // the iframe can't resolve elements there, so it would clear the selection.
270
- if (selection && compRect.width > 0) {
271
- const overlayEl = overlayRef.current;
272
- if (overlayEl) {
273
- const overlayRect = overlayEl.getBoundingClientRect();
274
- const clickX = event.clientX - overlayRect.left;
275
- const clickY = event.clientY - overlayRect.top;
276
- const outsideComp =
277
- clickX < compRect.left ||
278
- clickX > compRect.left + compRect.width ||
279
- clickY < compRect.top ||
280
- clickY > compRect.top + compRect.height;
281
- if (outsideComp) return;
282
- }
283
- }
330
+ // Allow clicks anywhere on the overlay GSAP-translated elements can
331
+ // extend beyond the composition rect into the gray zone, and users need
332
+ // to select/deselect them by clicking there.
284
333
  onCanvasMouseDown(event, { preferClipAncestor: false });
285
334
  if (event.shiftKey) {
286
335
  suppressNextBoxMouseDownRef.current = true;
@@ -306,6 +355,36 @@ export const DomEditOverlay = memo(function DomEditOverlay({
306
355
 
307
356
  const target = event.target as HTMLElement | null;
308
357
  if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
358
+
359
+ // Start marquee if clicking on empty canvas (no element under pointer)
360
+ if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) {
361
+ const overlayEl = overlayRef.current;
362
+ if (overlayEl) {
363
+ const oRect = overlayEl.getBoundingClientRect();
364
+ const cx = event.clientX - oRect.left;
365
+ const cy = event.clientY - oRect.top;
366
+ const inComp =
367
+ cx >= compRect.left &&
368
+ cx <= compRect.left + compRect.width &&
369
+ cy >= compRect.top &&
370
+ cy <= compRect.top + compRect.height;
371
+ if (inComp) {
372
+ event.preventDefault();
373
+ event.stopPropagation();
374
+ suppressNextOverlayMouseDownRef.current = true;
375
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
376
+ marquee.marqueeRef.current = {
377
+ startX: cx,
378
+ startY: cy,
379
+ currentX: cx,
380
+ currentY: cy,
381
+ pointerId: event.pointerId,
382
+ pastThreshold: false,
383
+ };
384
+ return;
385
+ }
386
+ }
387
+ }
309
388
  };
310
389
 
311
390
  const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -332,44 +411,29 @@ export const DomEditOverlay = memo(function DomEditOverlay({
332
411
  className="absolute inset-0 z-10 pointer-events-auto outline-none"
333
412
  tabIndex={-1}
334
413
  aria-label="Composition canvas"
414
+ // Cursor follows marquee rect *state* (re-renders), not the mutable ref.
415
+ style={marquee.marqueeRect ? { cursor: "crosshair" } : undefined}
335
416
  onPointerDownCapture={(event) =>
336
417
  focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay)
337
418
  }
338
419
  onPointerDown={handleOverlayPointerDown}
339
420
  onMouseDown={handleOverlayMouseDown}
340
- onPointerMove={gestures.onPointerMove}
421
+ onPointerMove={marquee.onPointerMove}
341
422
  onPointerLeave={() => onCanvasPointerLeaveRef.current()}
342
- onPointerUp={gestures.onPointerUp}
343
- onPointerCancel={() => gestures.clearPointerState(selectionRef)}
423
+ onPointerUp={marquee.onPointerUp}
424
+ onPointerCancel={marquee.onPointerCancel}
344
425
  >
345
426
  {hoverSelection && hoverRect && compRect.width > 0 && (
346
427
  <div
347
428
  aria-hidden="true"
348
429
  data-dom-edit-hover-box="true"
349
- className="pointer-events-none absolute border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
350
- style={(() => {
351
- let br: string | number = 4;
352
- let cp: string | undefined;
353
- try {
354
- const el = hoverSelection.element;
355
- const tag = el.tagName.toLowerCase();
356
- if (tag !== "svg" && tag !== "img" && tag !== "video" && tag !== "canvas") {
357
- const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
358
- if (cs?.borderRadius && cs.borderRadius !== "0px") br = cs.borderRadius;
359
- if (cs?.clipPath && cs.clipPath !== "none") cp = cs.clipPath;
360
- }
361
- } catch {
362
- /* cross-origin guard */
363
- }
364
- return {
365
- left: hoverRect.left,
366
- top: hoverRect.top,
367
- width: hoverRect.width,
368
- height: hoverRect.height,
369
- borderRadius: br,
370
- clipPath: cp,
371
- };
372
- })()}
430
+ className="pointer-events-none absolute rounded-md border border-studio-accent/80 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
431
+ style={{
432
+ left: hoverRect.left,
433
+ top: hoverRect.top,
434
+ width: hoverRect.width,
435
+ height: hoverRect.height,
436
+ }}
373
437
  />
374
438
  )}
375
439
  {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
@@ -437,13 +501,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
437
501
  key={selectionKey}
438
502
  ref={boxRef}
439
503
  data-dom-edit-selection-box="true"
440
- className={`pointer-events-auto absolute ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
504
+ className={`pointer-events-auto absolute rounded-md ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
441
505
  style={{
442
506
  left: overlayRect.left,
443
507
  top: overlayRect.top,
444
508
  width: overlayRect.width,
445
509
  height: overlayRect.height,
446
- borderRadius: selectionShapeStyles.borderRadius,
447
510
  clipPath: selectionShapeStyles.clipPath,
448
511
  cursor:
449
512
  allowCanvasMovement && selection.capabilities.canApplyManualOffset
@@ -496,6 +559,27 @@ export const DomEditOverlay = memo(function DomEditOverlay({
496
559
  }}
497
560
  />
498
561
  ))}
562
+ <OffCanvasIndicators
563
+ rects={offCanvasRects}
564
+ elements={offCanvasElementsRef}
565
+ compRect={compRect}
566
+ selection={selection}
567
+ groupSelections={groupSelections}
568
+ activeCompositionPathRef={activeCompositionPathRef}
569
+ onSelectionChangeRef={onSelectionChangeRef}
570
+ />
571
+ {marquee.marqueeRect && (
572
+ <div
573
+ aria-hidden="true"
574
+ className="pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
575
+ style={{
576
+ left: marquee.marqueeRect.left,
577
+ top: marquee.marqueeRect.top,
578
+ width: marquee.marqueeRect.width,
579
+ height: marquee.marqueeRect.height,
580
+ }}
581
+ />
582
+ )}
499
583
  <GridOverlay
500
584
  visible={gridVisible}
501
585
  spacing={gridSpacing}