1- import { memo , useMemo , useRef , useState , type RefObject } from "react" ;
1+ import { memo , useEffect , useMemo , useRef , useState , type RefObject } from "react" ;
22import { useMountEffect } from "../../hooks/useMountEffect" ;
33import { 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" ;
58import {
69 type BlockedMoveState ,
710 type DomEditGroupPathOffsetCommit ,
@@ -67,8 +70,10 @@ interface DomEditOverlayProps {
6770 gridSpacing ?: number ;
6871 recordingState ?: GestureRecordingState ;
6972 onToggleRecording ?: ( ) => void ;
73+ onMarqueeSelect ?: ( selections : DomEditSelection [ ] , additive : boolean ) => void ;
7074}
7175
76+ // fallow-ignore-next-line complexity
7277export const DomEditOverlay = memo ( function DomEditOverlay ( {
7378 iframeRef,
7479 activeCompositionPath,
@@ -88,13 +93,16 @@ export const DomEditOverlay = memo(function DomEditOverlay({
8893 onGroupPathOffsetCommit,
8994 onBoxSizeCommit,
9095 onRotationCommit,
96+ onMarqueeSelect,
9197} : DomEditOverlayProps ) {
9298 const overlayRef = useRef < HTMLDivElement | null > ( null ) ;
9399 const boxRef = useRef < HTMLDivElement | null > ( null ) ;
100+ const onMarqueeSelectRef = useRef ( onMarqueeSelect ) ;
101+ onMarqueeSelectRef . current = onMarqueeSelect ;
94102
95103 const selectionShapeStyles = ( ( ) => {
96104 const fallback = {
97- borderRadius : 4 as string | number ,
105+ borderRadius : 8 as string | number ,
98106 clipPath : undefined as string | undefined ,
99107 } ;
100108 if ( ! selection ?. element ) return fallback ;
@@ -213,6 +221,47 @@ export const DomEditOverlay = memo(function DomEditOverlay({
213221 return ( ) => cancelAnimationFrame ( frame ) ;
214222 } ) ;
215223
224+ // Off-canvas element indicators — dashed outlines for elements positioned
225+ // outside the composition bounds so users can find them.
226+ const offCanvasElementsRef = useRef < Map < string , HTMLElement > > ( new Map ( ) ) ;
227+ const [ offCanvasRects , setOffCanvasRects ] = useState <
228+ { key : string ; left : number ; top : number ; width : number ; height : number } [ ]
229+ > ( [ ] ) ;
230+ useEffect ( ( ) => {
231+ const iframe = iframeRef . current ;
232+ const overlay = overlayRef . current ;
233+ if ( ! iframe || ! overlay || compRect . width <= 0 ) {
234+ setOffCanvasRects ( [ ] ) ;
235+ return ;
236+ }
237+ const doc = iframe . contentDocument ;
238+ if ( ! doc ) return ;
239+ const root = doc . querySelector < HTMLElement > ( "[data-composition-id]" ) ?? doc . body ;
240+ const acp = activeCompositionPath ?? "index.html" ;
241+ const items = collectDomEditLayerItems ( root , {
242+ activeCompositionPath : acp ,
243+ isMasterView : ! acp || acp === "index.html" ,
244+ } ) ;
245+ const rects : typeof offCanvasRects = [ ] ;
246+ const elMap = new Map < string , HTMLElement > ( ) ;
247+ for ( const item of items ) {
248+ if ( ! isElementComputedVisible ( item . element ) ) continue ;
249+ const r = toOverlayRect ( overlay , iframe , item . element ) ;
250+ if ( ! r ) continue ;
251+ const partiallyOutside =
252+ r . left < compRect . left ||
253+ r . left + r . width > compRect . left + compRect . width ||
254+ r . top < compRect . top ||
255+ r . top + r . height > compRect . top + compRect . height ;
256+ if ( partiallyOutside ) {
257+ rects . push ( { key : item . key , left : r . left , top : r . top , width : r . width , height : r . height } ) ;
258+ elMap . set ( item . key , item . element ) ;
259+ }
260+ }
261+ offCanvasElementsRef . current = elMap ;
262+ setOffCanvasRects ( rects ) ;
263+ } , [ iframeRef , compRect , activeCompositionPath , selection ] ) ;
264+
216265 const gestures = createDomEditOverlayGestureHandlers ( {
217266 overlayRef,
218267 iframeRef,
@@ -238,6 +287,15 @@ export const DomEditOverlay = memo(function DomEditOverlay({
238287 snapGuidesRef,
239288 } ) ;
240289
290+ const marquee = useMarqueeGestures ( {
291+ iframeRef,
292+ overlayRef,
293+ activeCompositionPathRef,
294+ onMarqueeSelectRef,
295+ selectionRef,
296+ gestures,
297+ } ) ;
298+
241299 const selectionKey = useMemo ( ( ) => {
242300 if ( ! selection ) return "none" ;
243301 return `${ selection . sourceFile } :${ selection . id ?? selection . selector ?? selection . label } :${ selection . selectorIndex ?? 0 } ` ;
@@ -265,22 +323,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
265323 }
266324 const target = event . target as HTMLElement | null ;
267325 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- }
326+ // Allow clicks anywhere on the overlay — GSAP-translated elements can
327+ // extend beyond the composition rect into the gray zone, and users need
328+ // to select/deselect them by clicking there.
284329 onCanvasMouseDown ( event , { preferClipAncestor : false } ) ;
285330 if ( event . shiftKey ) {
286331 suppressNextBoxMouseDownRef . current = true ;
@@ -306,6 +351,36 @@ export const DomEditOverlay = memo(function DomEditOverlay({
306351
307352 const target = event . target as HTMLElement | null ;
308353 if ( target ?. closest ( '[data-dom-edit-selection-box="true"]' ) ) return ;
354+
355+ // Start marquee if clicking on empty canvas (no element under pointer)
356+ if ( ! hoverSelectionRef . current && onMarqueeSelectRef . current && compRect . width > 0 ) {
357+ const overlayEl = overlayRef . current ;
358+ if ( overlayEl ) {
359+ const oRect = overlayEl . getBoundingClientRect ( ) ;
360+ const cx = event . clientX - oRect . left ;
361+ const cy = event . clientY - oRect . top ;
362+ const inComp =
363+ cx >= compRect . left &&
364+ cx <= compRect . left + compRect . width &&
365+ cy >= compRect . top &&
366+ cy <= compRect . top + compRect . height ;
367+ if ( inComp ) {
368+ event . preventDefault ( ) ;
369+ event . stopPropagation ( ) ;
370+ suppressNextOverlayMouseDownRef . current = true ;
371+ ( event . currentTarget as HTMLElement ) . setPointerCapture ( event . pointerId ) ;
372+ marquee . marqueeRef . current = {
373+ startX : cx ,
374+ startY : cy ,
375+ currentX : cx ,
376+ currentY : cy ,
377+ pointerId : event . pointerId ,
378+ pastThreshold : false ,
379+ } ;
380+ return ;
381+ }
382+ }
383+ }
309384 } ;
310385
311386 const handleBoxClick = ( event : React . MouseEvent < HTMLDivElement > ) => {
@@ -332,44 +407,28 @@ export const DomEditOverlay = memo(function DomEditOverlay({
332407 className = "absolute inset-0 z-10 pointer-events-auto outline-none"
333408 tabIndex = { - 1 }
334409 aria-label = "Composition canvas"
410+ style = { marquee . marqueeRef . current ?. pastThreshold ? { cursor : "crosshair" } : undefined }
335411 onPointerDownCapture = { ( event ) =>
336412 focusDomEditOverlayElement ( event . currentTarget as FocusableDomEditOverlay )
337413 }
338414 onPointerDown = { handleOverlayPointerDown }
339415 onMouseDown = { handleOverlayMouseDown }
340- onPointerMove = { gestures . onPointerMove }
416+ onPointerMove = { marquee . onPointerMove }
341417 onPointerLeave = { ( ) => onCanvasPointerLeaveRef . current ( ) }
342- onPointerUp = { gestures . onPointerUp }
343- onPointerCancel = { ( ) => gestures . clearPointerState ( selectionRef ) }
418+ onPointerUp = { marquee . onPointerUp }
419+ onPointerCancel = { marquee . onPointerCancel }
344420 >
345421 { hoverSelection && hoverRect && compRect . width > 0 && (
346422 < div
347423 aria-hidden = "true"
348424 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- } ) ( ) }
425+ 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)]"
426+ style = { {
427+ left : hoverRect . left ,
428+ top : hoverRect . top ,
429+ width : hoverRect . width ,
430+ height : hoverRect . height ,
431+ } }
373432 />
374433 ) }
375434 { hasGroupSelection && groupOverlayItems . length > 1 && groupBounds && compRect . width > 0 && (
@@ -437,13 +496,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
437496 key = { selectionKey }
438497 ref = { boxRef }
439498 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` }
499+ 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` }
441500 style = { {
442501 left : overlayRect . left ,
443502 top : overlayRect . top ,
444503 width : overlayRect . width ,
445504 height : overlayRect . height ,
446- borderRadius : selectionShapeStyles . borderRadius ,
447505 clipPath : selectionShapeStyles . clipPath ,
448506 cursor :
449507 allowCanvasMovement && selection . capabilities . canApplyManualOffset
@@ -496,6 +554,64 @@ export const DomEditOverlay = memo(function DomEditOverlay({
496554 } }
497555 />
498556 ) ) }
557+ { offCanvasRects
558+ . filter ( ( r ) => {
559+ const selEl = selection ?. element ;
560+ return ! selEl || offCanvasElementsRef . current . get ( r . key ) !== selEl ;
561+ } )
562+ . map ( ( r ) => {
563+ const pos = { left : r . left , top : r . top , width : r . width , height : r . height } ;
564+ const cL = Math . max ( 0 , compRect . left - r . left ) ;
565+ const cT = Math . max ( 0 , compRect . top - r . top ) ;
566+ const cR = Math . min ( r . width , compRect . left + compRect . width - r . left ) ;
567+ const cB = Math . min ( r . height , compRect . top + compRect . height - r . top ) ;
568+ const hasInside = cL < cR && cT < cB ;
569+ const clipOutside = hasInside
570+ ? `polygon(evenodd, 0 0, ${ r . width } px 0, ${ r . width } px ${ r . height } px, 0 ${ r . height } px, 0 0, ${ cL } px ${ cT } px, ${ cR } px ${ cT } px, ${ cR } px ${ cB } px, ${ cL } px ${ cB } px, ${ cL } px ${ cT } px)`
571+ : undefined ;
572+ const clipInside = `inset(${ cT } px ${ Math . max ( 0 , r . width - cR ) } px ${ Math . max ( 0 , r . height - cB ) } px ${ cL } px round 6px)` ;
573+ const handleClick = async ( e : React . MouseEvent ) => {
574+ e . stopPropagation ( ) ;
575+ const el = offCanvasElementsRef . current . get ( r . key ) ;
576+ if ( ! el ) return ;
577+ const { resolveDomEditSelection } = await import ( "./domEditingLayers" ) ;
578+ const acp = activeCompositionPathRef . current ?? "index.html" ;
579+ const sel = await resolveDomEditSelection ( el , {
580+ activeCompositionPath : acp ,
581+ isMasterView : ! acp || acp === "index.html" ,
582+ skipSourceProbe : true ,
583+ } ) ;
584+ if ( sel ) onSelectionChangeRef . current ( sel , { revealPanel : true } ) ;
585+ } ;
586+ return (
587+ < div key = { `offcanvas-${ r . key } ` } className = "pointer-events-none absolute" style = { pos } >
588+ { /* Dashed layer — clipped to exclude canvas area */ }
589+ < div
590+ className = "pointer-events-auto absolute inset-0 border-2 border-dashed border-studio-accent/60 rounded-md cursor-pointer hover:border-studio-accent hover:bg-studio-accent/10 transition-colors"
591+ style = { clipOutside ? { clipPath : clipOutside } : undefined }
592+ title = { `Off-canvas: ${ r . key } — click to select` }
593+ onClick = { handleClick }
594+ />
595+ { /* Solid layer — clipped to canvas bounds, covers inside portion */ }
596+ < div
597+ className = "pointer-events-none absolute inset-0 border border-studio-accent/80 rounded-md bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
598+ style = { { clipPath : clipInside } }
599+ />
600+ </ div >
601+ ) ;
602+ } ) }
603+ { marquee . marqueeRect && (
604+ < div
605+ aria-hidden = "true"
606+ className = "pointer-events-none absolute border border-dashed border-studio-accent bg-studio-accent/10"
607+ style = { {
608+ left : marquee . marqueeRect . left ,
609+ top : marquee . marqueeRect . top ,
610+ width : marquee . marqueeRect . width ,
611+ height : marquee . marqueeRect . height ,
612+ } }
613+ />
614+ ) }
499615 < GridOverlay
500616 visible = { gridVisible }
501617 spacing = { gridSpacing }
0 commit comments