22
33import React , { act } from "react" ;
44import { createRoot } from "react-dom/client" ;
5- import { describe , expect , it , vi } from "vitest" ;
5+ import { beforeEach , describe , expect , it , vi } from "vitest" ;
66import { Window } from "happy-dom" ;
77import {
88 DomEditOverlay ,
@@ -19,13 +19,21 @@ import type { DomEditSelection } from "./domEditing";
1919// React 19 warns unless the test environment opts into act().
2020globalThis . IS_REACT_ACT_ENVIRONMENT = true ;
2121
22+ const gestureSpies = vi . hoisted ( ( ) => ( {
23+ startGesture : vi . fn ( ( ) => true ) ,
24+ startGroupDrag : vi . fn ( ) ,
25+ onPointerMove : vi . fn ( ) ,
26+ onPointerUp : vi . fn ( ) ,
27+ clearPointerState : vi . fn ( ) ,
28+ } ) ) ;
29+
2230vi . mock ( "./useDomEditOverlayGestures" , ( ) => ( {
2331 createDomEditOverlayGestureHandlers : ( ) => ( {
24- startGesture : ( ) => true ,
25- startGroupDrag : ( ) => { } ,
26- onPointerMove : ( ) => { } ,
27- onPointerUp : ( ) => { } ,
28- clearPointerState : ( ) => { } ,
32+ startGesture : gestureSpies . startGesture ,
33+ startGroupDrag : gestureSpies . startGroupDrag ,
34+ onPointerMove : gestureSpies . onPointerMove ,
35+ onPointerUp : gestureSpies . onPointerUp ,
36+ clearPointerState : gestureSpies . clearPointerState ,
2937 } ) ,
3038} ) ) ;
3139
@@ -34,9 +42,18 @@ vi.mock("./useDomEditOverlayRects", async () => {
3442 const { rectsEqual } = await import ( "./domEditOverlayGeometry" ) ;
3543
3644 return {
37- useDomEditOverlayRects : ( ) => {
38- const [ overlayRect , setOverlayRectState ] = React . useState ( null ) ;
39- const overlayRectRef = React . useRef ( null ) ;
45+ useDomEditOverlayRects : ( options : { selectionRef : { current : unknown } } ) => {
46+ const defaultSelectionRect = {
47+ left : 24 ,
48+ top : 36 ,
49+ width : 180 ,
50+ height : 72 ,
51+ editScaleX : 1 ,
52+ editScaleY : 1 ,
53+ } ;
54+ const initialOverlayRect = options . selectionRef . current ? defaultSelectionRect : null ;
55+ const [ overlayRect , setOverlayRectState ] = React . useState ( initialOverlayRect ) ;
56+ const overlayRectRef = React . useRef ( initialOverlayRect ) ;
4057 const [ groupOverlayItems , setGroupOverlayItemsState ] = React . useState ( [ ] ) ;
4158 const groupOverlayItemsRef = React . useRef ( [ ] ) ;
4259
@@ -85,6 +102,30 @@ vi.mock("./domEditOverlayGeometry", async () => {
85102 } ;
86103} ) ;
87104
105+ function createOverlayProps ( args : {
106+ iframeRef : { current : HTMLIFrameElement | null } ;
107+ selection : DomEditSelection | null ;
108+ hoverSelection : DomEditSelection | null ;
109+ onSelectionChange : ( next : DomEditSelection ) => void ;
110+ } ) {
111+ return {
112+ iframeRef : args . iframeRef ,
113+ activeCompositionPath : null ,
114+ selection : args . selection ,
115+ hoverSelection : args . hoverSelection ,
116+ groupSelections : [ ] ,
117+ onCanvasMouseDown : ( ) => { } ,
118+ onCanvasPointerMove : ( ) => Promise . resolve ( args . hoverSelection ?? args . selection ) ,
119+ onCanvasPointerLeave : ( ) => { } ,
120+ onSelectionChange : args . onSelectionChange ,
121+ onBlockedMove : ( ) => { } ,
122+ onPathOffsetCommit : ( ) => { } ,
123+ onGroupPathOffsetCommit : ( ) => { } ,
124+ onBoxSizeCommit : ( ) => { } ,
125+ onRotationCommit : ( ) => { } ,
126+ } ;
127+ }
128+
88129describe ( "focusDomEditOverlayElement" , ( ) => {
89130 it ( "focuses the canvas overlay without scrolling" , ( ) => {
90131 const calls : Array < FocusOptions | undefined > = [ ] ;
@@ -97,7 +138,94 @@ describe("focusDomEditOverlayElement", () => {
97138} ) ;
98139
99140describe ( "DomEditOverlay" , ( ) => {
100- it ( "renders selected bounds right after clicking a movable selection" , async ( ) => {
141+ beforeEach ( ( ) => {
142+ gestureSpies . startGesture . mockClear ( ) ;
143+ gestureSpies . startGroupDrag . mockClear ( ) ;
144+ gestureSpies . onPointerMove . mockClear ( ) ;
145+ gestureSpies . onPointerUp . mockClear ( ) ;
146+ gestureSpies . clearPointerState . mockClear ( ) ;
147+ } ) ;
148+
149+ it ( "does not start a drag from a stale hover target on canvas pointer-down" , ( ) => {
150+ const host = document . createElement ( "div" ) ;
151+ document . body . append ( host ) ;
152+ const root = createRoot ( host ) ;
153+ const selection : DomEditSelection = {
154+ element : document . createElement ( "div" ) ,
155+ id : "cta-label" ,
156+ selector : ".cta-label" ,
157+ selectorIndex : 0 ,
158+ sourceFile : "index.html" ,
159+ tagName : "span" ,
160+ label : "CTA Label" ,
161+ textContent : "Add to basket" ,
162+ textFields : [ ] ,
163+ capabilities : {
164+ canEditText : true ,
165+ canEditLayout : true ,
166+ canMove : true ,
167+ canApplyManualOffset : true ,
168+ canApplyManualSize : false ,
169+ canApplyManualRotation : false ,
170+ canAdjustOpacity : true ,
171+ canAdjustFill : true ,
172+ canAdjustBorderRadius : true ,
173+ canAdjustStroke : true ,
174+ canAdjustShadow : true ,
175+ canAdjustZIndex : true ,
176+ } ,
177+ computedStyle : {
178+ display : "inline" ,
179+ position : "static" ,
180+ } ,
181+ } ;
182+
183+ let currentSelection : DomEditSelection | null = null ;
184+ const iframeRef = { current : document . createElement ( "iframe" ) as HTMLIFrameElement | null } ;
185+
186+ function Harness ( ) {
187+ const [ selected , setSelected ] = React . useState < DomEditSelection | null > ( null ) ;
188+ currentSelection = selected ;
189+
190+ return React . createElement (
191+ DomEditOverlay ,
192+ createOverlayProps ( {
193+ iframeRef,
194+ selection : selected ,
195+ hoverSelection : selection ,
196+ onSelectionChange : ( next : DomEditSelection ) => setSelected ( next ) ,
197+ } ) ,
198+ ) ;
199+ }
200+
201+ act ( ( ) => {
202+ root . render ( React . createElement ( Harness ) ) ;
203+ } ) ;
204+
205+ const overlay = host . querySelector ( '[aria-label="Composition canvas"]' ) as HTMLDivElement ;
206+ expect ( overlay ) . toBeTruthy ( ) ;
207+
208+ act ( ( ) => {
209+ overlay . dispatchEvent (
210+ new PointerEvent ( "pointerdown" , {
211+ bubbles : true ,
212+ button : 0 ,
213+ clientX : 120 ,
214+ clientY : 80 ,
215+ } ) ,
216+ ) ;
217+ } ) ;
218+
219+ expect ( gestureSpies . startGesture ) . not . toHaveBeenCalled ( ) ;
220+ expect ( currentSelection ) . toBe ( null ) ;
221+
222+ act ( ( ) => {
223+ root . unmount ( ) ;
224+ } ) ;
225+ host . remove ( ) ;
226+ } ) ;
227+
228+ it ( "starts movement from the selected bounds" , async ( ) => {
101229 // The overlay's compRect updates via a RAF loop reading iframe + overlay
102230 // getBoundingClientRect. happy-dom returns all zeros for newly-created
103231 // elements with no layout, so without stubs the RAF early-returns
@@ -153,32 +281,24 @@ describe("DomEditOverlay", () => {
153281 } ,
154282 } ;
155283
156- let currentSelection : DomEditSelection | null = null ;
284+ let currentSelection : DomEditSelection | null = selection ;
157285 const iframeRef = { current : document . createElement ( "iframe" ) as HTMLIFrameElement | null } ;
158286 const originalPointerCapture = HTMLDivElement . prototype . setPointerCapture ;
159287 HTMLDivElement . prototype . setPointerCapture = ( ) => { } ;
160288
161289 function Harness ( ) {
162- const [ selected , setSelected ] = React . useState < DomEditSelection | null > ( null ) ;
290+ const [ selected , setSelected ] = React . useState < DomEditSelection | null > ( selection ) ;
163291 currentSelection = selected ;
164292
165- return React . createElement ( DomEditOverlay , {
166- iframeRef,
167- activeCompositionPath : null ,
168- selection : selected ,
169- // Simulate the element being hovered before pointer-down (real users always hover first)
170- hoverSelection : selection ,
171- groupSelections : [ ] ,
172- onCanvasMouseDown : ( ) => { } ,
173- onCanvasPointerMove : ( ) => Promise . resolve ( selection ) ,
174- onCanvasPointerLeave : ( ) => { } ,
175- onSelectionChange : ( next : DomEditSelection ) => setSelected ( next ) ,
176- onBlockedMove : ( ) => { } ,
177- onPathOffsetCommit : ( ) => { } ,
178- onGroupPathOffsetCommit : ( ) => { } ,
179- onBoxSizeCommit : ( ) => { } ,
180- onRotationCommit : ( ) => { } ,
181- } ) ;
293+ return React . createElement (
294+ DomEditOverlay ,
295+ createOverlayProps ( {
296+ iframeRef,
297+ selection : selected ,
298+ hoverSelection : null ,
299+ onSelectionChange : ( next : DomEditSelection ) => setSelected ( next ) ,
300+ } ) ,
301+ ) ;
182302 }
183303
184304 act ( ( ) => {
@@ -197,8 +317,13 @@ describe("DomEditOverlay", () => {
197317 const overlay = host . querySelector ( '[aria-label="Composition canvas"]' ) as HTMLDivElement ;
198318 expect ( overlay ) . toBeTruthy ( ) ;
199319
320+ const selectionBox = host . querySelector (
321+ '[data-dom-edit-selection-box="true"]' ,
322+ ) as HTMLDivElement ;
323+ expect ( selectionBox ) . toBeTruthy ( ) ;
324+
200325 act ( ( ) => {
201- overlay . dispatchEvent (
326+ selectionBox . dispatchEvent (
202327 new PointerEvent ( "pointerdown" , {
203328 bubbles : true ,
204329 button : 0 ,
@@ -209,7 +334,10 @@ describe("DomEditOverlay", () => {
209334 } ) ;
210335
211336 expect ( currentSelection ) . toBe ( selection ) ;
212- expect ( host . querySelector ( '[data-dom-edit-selection-box="true"]' ) ) . toBeTruthy ( ) ;
337+ expect ( gestureSpies . startGesture ) . toHaveBeenCalledWith (
338+ "drag" ,
339+ expect . objectContaining ( { button : 0 } ) ,
340+ ) ;
213341
214342 act ( ( ) => {
215343 root . unmount ( ) ;
0 commit comments