본문 바로가기
Font-end/Javascript

[Context Menu] 나만의 컨텍스트 메뉴 만들기

by 허도치 2020. 5. 29.
0. 서론

 

  웹 브라우저에서 마우스 오른쪽 클릭을 하면 아래와 같이 'Context Menu'가 출력된다. 이 메뉴에는 뒤로가기, 새로고침, 다른 이름으로 저장 등 기본적으로 브라우저에서 제공되는 이벤트들이 있다. 평소처럼 아무 생각없이 이 메뉴들을 사용하다가, 문뜩 내 맘대로 꾸며보고 싶어졌다. 그래서, 또 직접 만들어 보았다.

 

  이번 포스트에서는 이 Context Menu를 만드는 방법에 대해서 다루어 볼 계획이다. 가장 간단하게 만들 수 있는 정적 Context Menu를 만들어보고, 좀 더 확장시킨 동적 Context Menu를 만들어볼 계획이다.

 

[ Context Menu ]

 

 

 

1. 정적 Context Menu 작성
1-1. index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<!DOCTYPE html>
<html>
  <head>
    <title>Cusotm Context Menu</title>
    <style type="text/css">
     html, body { height: 100%;
}

      .custom-context-menu {
        position: absolute;
        box-sizing: border-box;
        min-height: 100px;
        min-width: 200px;
        background-color: #ffffff;
        box-shadow: 0 0 1px 2px lightgrey;
      }
      
      .custom-context-menu ul {
        list-style: none;
        padding: 0;
        background-color: transparent;
      }
      
      .custom-context-menu li {
        padding: 3px 5px;
        cursor: pointer;
      }
      
      .custom-context-menu li:hover {
        background-color: #f0f0f0;
      }    
    </style>
  </head>
  <body>
    <nav>
      <ul>
        <li><a href="http://gmail.com/">구글</a></li>
        <li><a href="http://heodolf.tistory.com/">블로그</a></li>
        <li><img src="https://tistory1.daumcdn.net/tistory/2803323/skin/images/img.jpg" width="100"></li>
      </ul>
    </nav>
    <div id='dochi_context_menu' class="custom-context-menu" style="display: none;">
      <ul>
        <li><a>메뉴1</a></li>
        <li><a>메뉴2</a></li>
      </ul>
    </div>
    <script type="text/javascript">
      // Context Menu 생성
      function handleCreateContextMenu(event){
        // 기본 Context Menu가 나오지 않게 차단
        event.preventDefault();
        
        const ctxMenu = document.getElementById('dochi_context_menu');
        
        // 노출 설정
        ctxMenu.style.display = 'block';
        
        // 위치 설정
        ctxMenu.style.top = event.pageY+'px';
        ctxMenu.style.left = event.pageX+'px';
      }
      
      // Context Menu 제거
      function handleClearContextMenu(event){
        const ctxMenu = document.getElementById('dochi_context_menu');
        
        // 노출 초기화
        ctxMenu.style.display = 'none';   
        ctxMenu.style.top = null;
        ctxMenu.style.left = null;
      }
      
      // 이벤트 바인딩
      document.addEventListener('contextmenu', handleCreateContextMenu, false);
      document.addEventListener('click', handleClearContextMenu, false);
    </script>
  </body>
</html>
cs

1. [ 5~30 ln ] - Context Menu 스타일 설정.

 

2. [ 33~39 ln ] - 간단한 네비게이션 메뉴.

   - 현재 예제에서는 사용하지 않음.

 

3. [ 41~46 ln ] - 정적으로 생성한 Context Menu Element.

   - 스타일에 'display: none;' 을 추가하여 평소에는 보이지 않도록 설정.

 

4. [ 49~61 ln ] - Context Menu를 노출시키는 이벤트 핸들러.

   - [ 51 ln ] - 기존 웹브라우저에서 노출되는 Context Menu를 차단.

   - [ 53 ln ] - 정적으로 생성한 Context Menu Element를 가져오기.

   - [ 56~60 ln ] - 새로운 Context Menu가 출력되도록 스타일 설정.

 

5. [ 64~71 ln ] - Context Menu를 숨기는 이벤트 핸들러.

   - [ 65 ln ] - 정적으로 생성한 Context Menu Element를 가져오기.

   - [ 68~70 ln ] - Context Menu가 숨겨지도록 스타일 설정.

 

6. [ 74~75 ln ] - 이벤트 바인딩.

   - [ 74 ln ] - document에서 마우스 오른쪽 클릭시 Context Menu가 노출되는 이벤트 바인딩.

   - [ 75 ln ] - document 왼쪽 클릭시 Context Menu가 숨겨지는 이벤트 바인딩.

 

 

 

1-2. 실행 결과

[ 정적 Context Menu 실행 화면 ]

  위에서 작성한 html파일을 웹 브라우저에서 실행한 후 마우스 오른쪽 클릭을 하면 위와 같이 내가 만든 Context Menu가 출력되는 것을 확인할 수 있다.

 

 

 

* 다음 예제부터는 내용이 길어져서 <head>, <body>, <script>로 나누어서 작성하도록 하겠음.

 

 

 

2. 동적 Context Menu 작성
2-1. index.html 작성 - <head>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
  <head>
    <title>Cusotm Context Menu</title>
    <style type="text/css">
      html, body { height: 100%; }
      
      .custom-context-menu {
        position: absolute;
        box-sizing: border-box;
        min-height: 100px;
        min-width: 200px;
        background-color: #ffffff;
        box-shadow: 0 0 1px 2px lightgrey;
      }
      
      .custom-context-menu ul {
        list-style: none;
        padding: 0;
        background-color: transparent;
      }
      
      .custom-context-menu li {
        padding: 3px 5px;
        cursor: pointer;
      }
      
      .custom-context-menu li:hover {
        background-color: #f0f0f0;
      }
    
    </style>
  </head>
cs

  - 1번 예제와 같음.

 

 

2-2. index.html 작성 - <body>
1
2
3
4
5
6
7
    <nav>
      <ul>
        <li><a href="http://gmail.com/">구글</a></li>
        <li><a href="http://heodolf.tistory.com/">블로그</a></li>
        <li><img src="https://tistory1.daumcdn.net/tistory/2803323/skin/images/img.jpg" width="100"></li>
      </ul>
    </nav>
cs

  - 1번 예제에서 정적으로 생성한 Context Menu를 삭제.

 

 

2-3. index.html 작성 - <script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
    <script type="text/javascript">
      // Context Menu List 렌더링
      function renderContextMenuList( list ){
        // List Element 생성
        const ctxMenuList = document.createElement('ul');
        
        // List Item 생성
        list.forEach(function( item ){
          // Item Element 생성
          const ctxMenuItem = document.createElement('li');
          const ctxMenuItem_a = document.createElement('a');
          const ctxMenuItem_a_text = document.createTextNode(item.label);
          
          // 클릭 이벤트 설정
          if( item.onClick ){
            ctxMenuItem.addEventListener'click', item.onClick, false );
          }
 
          // Item 추가
          ctxMenuItem_a.appendChild( ctxMenuItem_a_text );
          ctxMenuItem.appendChild( ctxMenuItem_a );
          ctxMenuList.appendChild( ctxMenuItem );
        });
        
        // List Element 반환
        return ctxMenuList;
      }
    
      // Context Menu 생성
      function handleCreateContextMenu(event){
        // 기본 Context Menu가 나오지 않게 차단
        event.preventDefault();
        
        // Context Menu Element 생성
        const ctxMenuId = 'dochi_context_menu';
        const ctxMenu = document.createElement('div');
        
        // Context Menu Element 옵션 설정
        ctxMenu.id = ctxMenuId;
        ctxMenu.className = 'custom-context-menu';
        
        // 위치 설정
        ctxMenu.style.top = event.pageY+'px';
        ctxMenu.style.left = event.pageX+'px';
        
        // 메뉴 목록 생성
        ctxMenu.appendChild(renderContextMenuList([
          {
           label: "메뉴1",
            onClick: function(event){
              alertevent.target.innerText );
            }
          },
          {
           label: "메뉴2",
            onClick: function(event){
              alertevent.target.innerText );
            }
          },
        ]));
        
        // 이전 Element 삭제
        const prevCtxMenu = document.getElementById( ctxMenuId );
        if( prevCtxMenu ){
          prevCtxMenu.remove();
        }
        
        // Body에 Context Menu를 추가.
        document.body.appendChild( ctxMenu );
      }
      
      // Context Menu 제거
      function handleClearContextMenu(event){
        const ctxMenu = document.getElementById('dochi_context_menu');
        if( ctxMenu ){
          ctxMenu.remove();
        }
      }
      
      // 이벤트 바인딩
      document.addEventListener('contextmenu', handleCreateContextMenu, false);
      document.addEventListener('click', handleClearContextMenu, false);
    
    </script>
cs

1. [ 3~27 ln ] - Context Menu에 추가할 목록을 동적으로 생성하는 함수.

   - [ 5 ln ] - List Element 생성.

   - [ 7~20 ln ] - List Item Element를 생성한 후 List Element에 추가.

 

2. [ 30~78 ln ] - Context Menu를 생성하는 이벤트 핸들러.

   - [ 35~44 ln ] - Context Menu Element 동적 생성.

   - [ 47~60 ln ] - Context Menu 목록 추가.

     - label: 목록에 출력할 텍스트.

     - onClick: 클릭시 발생시킬 이벤트.

   - [ 63~66 ln ] - 이전에 생성된 Context Menu가 있으면 삭제.

      - 중복 생성 방지.

   - [ 69 ln ] - Context Menu를 body에 추가.

 

3. [ 73~79 ln ] - Context Menu를 삭제하는 이벤트 핸들러.

 

4. [ 81~82 ln ] - 이벤트 바인딩.

   - [ 81 ln ] - document에서 마우스 오른쪽 클릭시 Context Menu가 생성되는 이벤트 바인딩.

   - [ 82 ln ] - document 왼쪽 클릭시 Context Menu가 삭제되는 이벤트 바인딩.

 

 

 

2-4. 실행 결과

[ 동적 Context Menu 실행 화면 ]

  화면에서 보여지는 부분은 정적 Context Menu 예제와 ( 이벤트만 추가되었을 뿐 ) 다를게 없다. 하지만, 내부적으로 돌아가는 로직을 살펴보면 크게 다르다. 우선, 정적 Context Menu는 이벤트 핸들러에서 단순히 노출여부만 판단하고 미리 생성된 Element를 보여주고 감추는 방식이기 때문에 로직은 단순하지만, 화면마다 특화된 메뉴 목록을 구성하기 힘들다. 반면에, 동적 Context Menu는 로직이 조금 복잡하지만, 상황에 따라 목록을 추가 삭제 하거나, 전혀 다른 구조로로 구성할 수도 있다.

 

 

 

3. 확장된 동적 Context Menu
3-1. index.html - <script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
(function(){
  const style = document.createElement('style');
  style.id = 'style-context-menu';
  style.type = 'text/css';
  style.innerHTML = [
    'html, body { height: 100%; }',        
    '.custom-context-menu {',
    '  z-index: 99999999;',
    '  position: absolute;',
    '  box-sizing: border-box;',
    '  min-height: 100px;',
    '  min-width: 200px;',
    '  background-color: #ffffff;',
    '  box-shadow: 0 0 1px 2px lightgrey;',
    '}',
    '.custom-context-menu ul {',
    '  list-style: none;',
    '  padding: 0;',
    '  background-color: transparent;',
    '}',
    '.custom-context-menu li {',
    '  padding: 3px 5px;',
    '  cursor: pointer;',
    '}',
    '.custom-context-menu li:hover {',
    '  background-color: #f0f0f0;',
    '}'
  ].join('\n');
  
  const prevStyle = document.getElementById(style.id);
  if( prevStyle ){
    document.head.removeChild( prevStyle );
  }
  document.head.appendChild( style );
})();
 
// 두개의 Object 결합
// target -> source
function combineProps( sourceProps, targetProps ){
  if!targetProps ) return sourceProps;
  
  const combinedProps = {}
  
  // 기본 옵션과 사용자 옵션 결합
  if( sourceProps instanceof Object && !Array.isArray( sourceProps ) ){
    // source와 target의 key 목록
    const sourceKeys = Object.keys(sourceProps);
    const targetKeys = Object.keys(targetProps);
    
    // 키 중복 제거
    const propKeys = sourceKeys.concat(targetKeys).reduce(function(prev, crnt, idx){
      const arr = idx === 1 ? [prev] : prev;
      
      return arr.indexOf( crnt ) === -1 ? arr.concat(crnt) : arr;
    });
    
    // 결합하기
    propKeys.forEach(function(key){
      const sourceValue = sourceProps[key];
      const targetValue = targetProps[key];
    
      // 둘다 존재하면 덮어쓰기
      if( targetValue && sourceValue ){
        if ( targetValue instanceof Object ) {
          ifArray.isArray( targetValue ) ){
            // 배열이면 Concat
            combinedProps[key] = [].concat( sourceValue, targetValue );
          } else {
            // JSON이면 assign
            combinedProps[key] = Object.assign({}, sourceValue, targetValue );
          }
        } else {
          // Object가 아니면, 그냥 덮어쓰기
          combinedProps[key] = targetValue;
        }
      } else {
        combinedProps[key] = sourceValue||targetValue;
      }
    });
  }
  
  return combinedProps;
}
 
// Context Menu Element 생성
function createElement( props ){
  const element = document.createElement( props.type );
  
  const keys = Object.keys(props);
  for(let i = 0; i < keys.length; i++){
    const key = keys[i];
    const value = props[key];
  
    switch( key ){
      case 'visible':
        if( value === false ) return null;
      case 'type':
        break;
      case 'className':
      case 'classList':
        [].concat(value).forEach(function(className){
          element.classList.add( className );
        })
        break;
      case 'style':
        Object.keys(value).forEach(function(styleName){
          element[key][styleName] = value[styleName];
        });
        break;
      case 'text':
        element.appendChild( document.createTextNode(value) );
        break;
      case 'onClick':
        if( value instanceof Function ){
          element.addEventListener('click', value, false);
        }
        break;
      case 'children':
        [].concat(value).map(function( childrenProps ){
          return createElement( childrenProps );
        }).forEach(function( children ){
          children && element.appendChild( children );
        });              
        break;
      default:
        element[key] = value;
    }
  }
  
  return element;
}
 
// Context Menu 렌더링
function renderContextMenu( customProps ){
  // Default Property 설정
  const props = combineProps({
    type: 'div',
    classList: [
      'custom-context-menu',
    ],
  }, customProps);
  
  // Element 생성
  return createElement( props );
}
 
// Context Menu 생성
function handleCreateContextMenu(event){
  // 기존 Context Menu가 나오지 않게 차단.
  event.preventDefault();
  
  const target = event.target;
  const imageTarget = target.tagName === "IMG" ? target : target.querySelector('img');
  
  const contextMenu = renderContextMenu({
    id: 'dochi_context_menu',
    className: 'test',
    style: {
      top: event.pageY+'px',
      left: event.pageX+'px',
    },
    children: [
      {
        type: 'ul',
        id: 'menu_list',
        className: 'menu-list',
        children: [
          {
            type: 'li',
            className: 'menu-item',
            children: {
              type: 'a',
              text: '뒤로가기',
              onClick: function( e ){
                window.history.back()
              },
            },
          },
          {
            // 오른쪽 클릭 대상이 이미지인 경우, 노출
            visible: !!imageTarget,
            type: 'li',
            className: 'menu-item',
            onClick: function( e ){
              // 구글 이미지 검색
              const URL = 'https://www.google.co.kr/searchbyimage';
              const src = imageTarget.getAttribute('src');
              const queryString = "image_url="+src;
              
              console.log( src )
              
              window.open( URL+'?'+queryString );
            },
            children: {
              type: 'a',
              text: "구글 이미지 검색",
            }
          }
        ]
      }
    ]
  });
  
  // 이전 Context Menu 삭제, 중복제거
  const prevCtxMenu = document.getElementById( contextMenu.id );
  if( prevCtxMenu ){
    document.body.removeChild(prevCtxMenu);
  }
  
  // Body에 Context Menu 추가
  document.body.appendChild( contextMenu );
}
 
// Context Menu 제거
function handleClearContextMenu(event){
  const contextMenus = document.getElementsByClassName('custom-context-menu');
  
  [].forEach.call(contextMenus, function(element){
    document.body.removeChild(element);          
  });
}
 
// 이벤트 바인딩
document.addEventListener('contextmenu', handleCreateContextMenu, false);
document.addEventListener('click', handleClearContextMenu, false);
 
cs

 

 

 

3-2. 실행 결과

[ 확장된 동적 Context Menu 실행 화면 1 ]
[ 확장된 동적 Context Menu 실행 화면 2 ]
[ 확장된 동적 Context Menu 실행 화면 3 ]

  확장된 동적 Context Menu는 이미지에서 마우스 오른쪽 클릭을 하면 '구글에서 이미지 검색' 메뉴가 생성되며, 클릭시 실제 검색이 되도록 구현해보았다. ( 고슴도치인데 두꺼비라고 나와서 속상.. )

  Javascript 소스만 작성한 이유는 이 스크립트를 개발자 도구에서 입력하면 어느 사이트에서든 적용이 가능하기 때문이다.

 

  이 예제는 Context Menu를 어떻게 활용하면 좋을까 생각하다가 구현해본 예제이다. 그냥 생각나는대로 구현한것이라 오류가 있을 수도 있는데, Props의 형식만 유지해주면 오류가 발생할 일은 없을 것이다. 그리고, 이미지의 URL 형식이 암호화되어 있는 경우, 검색이 안될 수 있으니 참고하길 바란다.

 

 

 

마치며

  이번 포스트에서는 간단하게 Context Menu를 만들어보았다. 잘만 이용하면 마우스 하나만으로 많은 작업들을 수월하게 할 수 있을 것 같다. 오늘 만든 예제를 더 강화해서 지금 개인적으로 진행하고 있는 프로젝트에 적용해봐야겠다.

댓글