Project

General

Profile

Patch #37486 » 0003-context_menu.patch

Takashi Kato, 2022-07-23 03:47

View differences:

public/javascripts/context_menu.js
1 1
/* Redmine - project management software
2 2
   Copyright (C) 2006-2022  Jean-Philippe Lang */
3 3

  
4
var observing;
4
let observing;
5 5

  
6 6
function rightClick(event) {
7
  var target = $(event.target);
7
  const target = $(event.target);
8 8
  if (target.is('a:not(.js-contextmenu)')) {return;}
9
  var tr = target.closest('.hascontextmenu').first();
9

  
10
  const tr = target.closest('.hascontextmenu').first();
10 11
  if (tr.length < 1) {return;}
11 12
  event.preventDefault();
13

  
12 14
  if (!isSelected(tr)) {
13 15
    unselectAll();
14 16
    addSelection(tr);
......
18 20
}
19 21

  
20 22
function click(event) {
21
  var target = $(event.target);
22
  var lastSelected;
23
  const target = $(event.target);
23 24

  
24 25
  if (target.is('a') && target.hasClass('submenu')) {
25 26
    event.preventDefault();
......
27 28
  }
28 29
  hide();
29 30
  if (target.is('a') || target.is('img')) { return; }
31

  
30 32
  if (event.which == 1 || (navigator.appVersion.match(/\bMSIE\b/))) {
31
    var tr = target.closest('.hascontextmenu').first();
33
    const tr = target.closest('.hascontextmenu').first();
32 34
    if (tr.length > 0) {
33 35
      // a row was clicked
34
      if (target.is('td.checkbox')) {
35
    	// the td containing the checkbox was clicked, toggle the checkbox
36
    	target = target.find('input').first();
37
    	target.prop("checked", !target.prop("checked"));
38
      }
39
      if (target.is('input')) {
40
        // a checkbox may be clicked
41
        if (target.prop('checked')) {
42
          tr.addClass('context-menu-selection');
43
        } else {
44
          tr.removeClass('context-menu-selection');
45
        }
46
      } else {
47
        if (event.ctrlKey || event.metaKey) {
48
          toggleSelection(tr);
49
          clearDocumentSelection();
50
        } else if (event.shiftKey) {
51
          lastSelected = getLastSelected();
52
          if (lastSelected.length) {
53
            var toggling = false;
54
            $('.hascontextmenu').each(function(){
55
              if (toggling || $(this).is(tr)) {
56
                addSelection($(this));
57
                clearDocumentSelection();
58
              }
59
              if ($(this).is(tr) || $(this).is(lastSelected)) {
60
                toggling = !toggling;
61
              }
62
            });
63
          } else {
64
            addSelection(tr);
65
          }
66
        } else {
67
          unselectAll();
68
          addSelection(tr);
69
        }
70
        setLastSelected(tr);
71
      }
36
      selectRows(target, tr, event);
72 37
    } else {
73 38
      // click is outside the rows
74 39
      if (target.is('a') && (target.hasClass('disabled') || target.hasClass('submenu'))) {
......
82 47
  }
83 48
}
84 49

  
50
export function selectRows($element, row, { ctrlKey, metaKey, shiftKey } ) {
51
  let $target = $element;
52
  if ($target.is('td.checkbox')) {
53
    // the td containing the checkbox was clicked, toggle the checkbox
54
    $target = $target.find('input').first();
55
    $target.prop("checked", !$target.prop("checked"));
56
  }
57
  if ($target.is('input')) {
58
    // a checkbox may be clicked
59
    row.toggleClass('context-menu-selection', $target.prop('checked'))
60
  } else {
61
    if (ctrlKey || metaKey) {
62
      toggleSelection(row);
63
      clearDocumentSelection();
64
    } else if (shiftKey) {
65
      const lastSelected = getLastSelected();
66
      if (lastSelected.length) {
67
        const selected = addMultipleSelection($('.hascontextmenu'), lastSelected, row);
68
        selected.forEach($e => addSelection($e));
69
      } else {
70
        addSelection(row);
71
      }
72
    } else {
73
      unselectAll();
74
      addSelection(row);
75
    }
76
    setLastSelected(row);
77
  }
78
}
79

  
80
export function addMultipleSelection($rows, lastSelected, clicked) {
81
  let toggling = false;
82
  const selected = [];
83
  $rows.each((i, elm) => {
84
    const $elm = $(elm);
85
    if (!$elm.is(lastSelected) && (toggling || $elm.is(clicked))) {
86
      selected.push($elm);
87
      clearDocumentSelection();
88
    }
89
    if ($elm.is(lastSelected) !== $elm.is(clicked)) {
90
      toggling = !toggling;
91
    }
92
  });
93
  return selected;
94
}
95

  
85 96
function create() {
86 97
  if ($('#context-menu').length < 1) {
87
    var menu = document.createElement("div");
98
    const menu = document.createElement("div");
88 99
    menu.setAttribute("id", "context-menu");
89 100
    menu.setAttribute("style", "display:none;");
90 101
    document.getElementById("content").appendChild(menu);
91 102
  }
92 103
}
93 104

  
94
function show(event) {
95
  var mouse_x = event.pageX;
96
  var mouse_y = event.pageY;  
97
  var mouse_y_c = event.clientY;  
98
  var render_x = mouse_x;
99
  var render_y = mouse_y;
100
  var dims;
101
  var menu_width;
102
  var menu_height;
103
  var window_width;
104
  var window_height;
105
  var max_width;
106
  var max_height;
107
  var url;
108

  
109
  $('#context-menu').css('left', (render_x + 'px'));
110
  $('#context-menu').css('top', (render_y + 'px'));
111
  $('#context-menu').html('');
112

  
113
  url = $(event.target).parents('form').first().data('cm-url');
105
function show({ pageX: mouse_x, pageY: mouse_y, clientY: mouse_y_c, target: target }) {
106
  const $element = $('#context-menu');
107
  $element.css({ left: `${mouse_x}px`, top: `${mouse_y}px` });
108
  $element.html('');
109

  
110
  const form = $(target).parents('form').first();
111
  const url  = form.data('cm-url');
114 112
  if (url == null) {alert('no url'); return;}
115 113

  
116 114
  $.ajax({
117 115
    url: url,
118
    data: $(event.target).parents('form').first().serialize(),
119
    success: function(data, textStatus, jqXHR) {
120
      $('#context-menu').html(data);
121
      menu_width = $('#context-menu').width();
122
      menu_height = $('#context-menu').height();
123
      max_width = mouse_x + 2*menu_width;
124
      max_height = mouse_y_c + menu_height;
125

  
126
      var ws = window_size();
127
      window_width = ws.width;
128
      window_height = ws.height;
129

  
130
      /* display the menu above and/or to the left of the click if needed */
131
      if (max_width > window_width) {
132
       render_x -= menu_width;
133
       $('#context-menu').addClass('reverse-x');
134
      } else {
135
       $('#context-menu').removeClass('reverse-x');
136
      }
116
    data: form.serialize(),
117
    success: (data, textStatus, jqXHR) => {
118
      $element.html(data);
137 119

  
138
      if (max_height > window_height) {
139
       render_y -= menu_height;
140
       $('#context-menu').addClass('reverse-y');
141
        // adding class for submenu
142
        if (mouse_y_c < 325) {
143
          $('#context-menu .folder').addClass('down');
144
        }
145
      } else {
146
        // adding class for submenu
147
        if (window_height - mouse_y_c < 345) {
148
          $('#context-menu .folder').addClass('up');
149
        } 
150
        $('#context-menu').removeClass('reverse-y');
151
      }
120
      const menu_width     = $element.width();
121
      const menu_height    = $element.height();
122
      const max_width      = mouse_x   + 2 * menu_width;
123
      const max_height     = mouse_y_c + menu_height;
124
      const ws             = window_size();
125

  
126
      const is_reverse_x   = max_width > ws.width;
127
      const arg_x          = { render_pos: mouse_x, menu_size: menu_width, position: 'left', class_name: 'reverse_x' }
128
      const action_x       = is_reverse_x ? reverseRenderAction(arg_x)
129
                                          : normalRenderAction(arg_x);
152 130

  
153
      if (render_x <= 0) render_x = 1;
154
      if (render_y <= 0) render_y = 1;
155
      $('#context-menu').css('left', (render_x + 'px'));
156
      $('#context-menu').css('top', (render_y + 'px'));
157
      $('#context-menu').show();
131
      const is_reverse_y   = max_height > ws.height;
132
      const arg_y          = { render_pos: mouse_y, menu_size: menu_height, position: 'top', class_name: 'reverse_y' }
133
      const action_y       = is_reverse_y ? reverseRenderAction(arg_y)
134
                                          : normalRenderAction(arg_y);
158 135

  
159
      //if (window.parseStylesheets) { window.parseStylesheets(); } // IE
136
      const arg_submenu    = { window_height: ws.height, mouse_y_c }
137
      const action_submenu = is_reverse_y ? reverseFolderAction(arg_submenu)
138
                                          : normalFolderAction(arg_submenu);
139
      action_x($element);
140
      action_y($element);
141
      // adding class for submenu
142
      action_submenu($element);
143

  
144
      $element.show();
160 145
    }
161 146
  });
162 147
}
163 148

  
149
export function reverseRenderAction({ render_pos, menu_size, position, class_name }) {
150
  return element => {
151
    element.addClass(class_name)
152
    const n = adjustLessThanZero(render_pos - menu_size);
153
    element.css(position, `${n}px`);
154
  }
155
}
156

  
157
export function normalRenderAction({ render_pos, menu_size, position, class_name }) {
158
  return element => {
159
    element.removeClass(class_name);
160
    const n = adjustLessThanZero(render_pos);
161
    element.css(position, `${n}px`);
162
  }
163
}
164

  
165
export function reverseFolderAction({ window_height, mouse_y_c }) {
166
  return element => {
167
    if (mouse_y_c < 325) {
168
      element.find('.folder').addClass('down');
169
    }
170
  }
171
}
172

  
173
export function normalFolderAction({ window_height, mouse_y_c }) {
174
  return element => {
175
    if (window_height - mouse_y_c < 345) {
176
      element.find('.folder').addClass('up');
177
    }
178
  }
179
}
180

  
181
function adjustLessThanZero(n) {
182
  return n <= 0 ? 1 : n;
183
}
184

  
164 185
function setLastSelected(tr) {
165 186
  $('.cm-last').removeClass('cm-last');
166 187
  tr.addClass('cm-last');
......
172 193

  
173 194
function unselectAll() {
174 195
  $('input[type=checkbox].toggle-selection').prop('checked', false);
175
  $('.hascontextmenu').each(function(){
176
    removeSelection($(this));
196
  $('.hascontextmenu').each((i, el) => {
197
    removeSelection($(el));
177 198
  });
178 199
  $('.cm-last').removeClass('cm-last');
179 200
}
......
220 241
function init() {
221 242
  create();
222 243
  unselectAll();
223
  
244

  
224 245
  if (!observing) {
225 246
    $(document).click(click);
226 247
    $(document).contextmenu(rightClick);
227 248
    $(document).on('click', '.js-contextmenu', rightClick);
228 249
    observing = true;
229 250
  }
251
  $('input[type=checkbox].toggle-selection').on('change', toggleIssuesSelection);
230 252
}
231 253

  
232
function toggleIssuesSelection(el) {
233
  var checked = $(this).prop('checked');
234
  var boxes = $(this).parents('table').find('input[name=ids\\[\\]]');
254
function toggleIssuesSelection(event) {
255
  const $target = $(event.target)
256
  const checked = $target.prop('checked');
257
  const boxes   = $target.parents('table').find('input[name=ids\\[\\]]');
235 258
  boxes.prop('checked', checked).parents('.hascontextmenu').toggleClass('context-menu-selection', checked);
236 259
}
237 260

  
238 261
function window_size() {
239
  var w;
240
  var h;
262
  let w;
263
  let h;
241 264
  if (window.innerWidth) {
242 265
    w = window.innerWidth;
243 266
    h = window.innerHeight;
......
251 274
  return {width: w, height: h};
252 275
}
253 276

  
254
$(document).ready(function(){
255
  init();
256
  $('input[type=checkbox].toggle-selection').on('change', toggleIssuesSelection);
257
});
277
init();
test/javascripts/context_menu.html
1
<html>
2
  <head>
3
    <script src="../../public/javascripts/jquery-3.6.0-ui-1.13.1-ujs-6.1.3.1.js"></script>
4
  </head>
5
  <body>
6
    <div id="content">
7
      <!-- test for row clicking -->
8
      <table>
9
        <tr class="hascontextmenu"><td class="checkbox"><input type="checkbox"/></td><td class="noinput"></td></tr>
10
        <tr class="hascontextmenu"><td class="checkbox"><input type="checkbox"/></td><td class="noinput"></td></tr>
11
        <tr class="hascontextmenu"><td class="checkbox"><input type="checkbox"/></td><td class="noinput"></td></tr>
12
        <tr class="hascontextmenu"><td class="checkbox"><input type="checkbox"/></td><td class="noinput"></td></tr>
13
        <tr class="hascontextmenu"><td class="checkbox"><input type="checkbox"/></td><td class="noinput"></td></tr>
14
        <tr class="hascontextmenu"><td class="checkbox"><input type="checkbox"/></td><td class="noinput"></td></tr>
15
      </table>
16
    </div>
17
    <!-- test for size and position of menu -->
18
    <div id="menu">
19
      <div class="folder"></div>
20
      <div class="folder"></div>
21
    </div>
22
  </body>
23
</html>
test/javascripts/context_menu.test.js
1
import { setup, suite, test } from 'mocha';
2
import { assert } from 'chai';
3
import { prepare } from './helper.js';
4

  
5
suite('context menu', () => {
6

  
7
  setup((done) => {
8
    prepare(import.meta.url, './context_menu.html').then(dom => {
9
      dom.window.addEventListener('load', (e) => {
10
        global.window   = dom.window;
11
        global.document = dom.window.document;
12
        global.$        = dom.window.$;
13
        done();
14
      });
15
    });
16
  });
17

  
18
  test('create contextmenu element', async () => {
19
    await import('../../public/javascripts/context_menu.js');
20
    const menu = document.getElementById('context-menu');
21
    assert.isNotNull(menu);
22
  });
23

  
24
  suite('when a row is clicked', async () => {
25
    let $rows;
26
    let selectRows;
27

  
28
    setup(async () => {
29
      $rows = $('.hascontextmenu');
30
      ({ selectRows } = await import('../../public/javascripts/context_menu.js'));
31
    });
32

  
33
    test('When the checkbox is clicked directly, select the row', () => {
34
      const target = $($rows[0]).find('input');
35
      target.prop('checked', true);
36
      const tr     = target.closest('.hascontextmenu').first();
37
      selectRows(target, tr, {} )
38
      assert.isTrue(tr.hasClass('context-menu-selection'));
39
    });
40

  
41
    test('When the td containing the checkbox is clicked, toggle the checkbox and select the row', () => {
42
      const target = $($rows[0]).find('td.checkbox');
43
      const tr     = target.closest('.hascontextmenu').first();
44
      selectRows(target, tr, {} )
45
      assert.isTrue(target.find('input').prop('checked'));
46
      assert.isTrue(tr.hasClass('context-menu-selection'));
47
    });
48

  
49
    test('When the td not containing the checkbox is clicked with no modifier, toggle the checkbox and select the row', () => {
50
      const target0 = $($rows[0]).find('td.noinput');
51
      const tr0     = target0.closest('.hascontextmenu').first();
52
      selectRows(target0, tr0, {} )
53
      assert.isTrue(tr0.find('input').prop('checked'));
54
      assert.isTrue(tr0.hasClass('context-menu-selection'));
55

  
56
      const target1 = $($rows[1]).find('td.noinput');
57
      const tr1     = target1.closest('.hascontextmenu').first();
58
      selectRows(target1, tr1, {} )
59

  
60
      assert.isFalse(tr0.find('input').prop('checked'));
61
      assert.isFalse(tr0.hasClass('context-menu-selection'));
62

  
63
      assert.isTrue(tr1.find('input').prop('checked'));
64
      assert.isTrue(tr1.hasClass('context-menu-selection'));
65
    });
66

  
67
    test('When the td not containing the checkbox is clicked with ctrl key, toggle the checkbox and select the row', () => {
68
      const target0 = $($rows[0]).find('td.noinput');
69
      const tr0     = target0.closest('.hascontextmenu').first();
70
      selectRows(target0, tr0, {} )
71
      assert.isTrue(tr0.find('input').prop('checked'));
72
      assert.isTrue(tr0.hasClass('context-menu-selection'));
73

  
74
      const target3 = $($rows[3]).find('td.noinput');
75
      const tr3     = target3.closest('.hascontextmenu').first();
76
      selectRows(target3, tr3, {ctrlKey:true} )
77

  
78
      assert.isTrue(tr0.find('input').prop('checked'));
79
      assert.isTrue(tr0.hasClass('context-menu-selection'));
80

  
81
      assert.isTrue(tr3.find('input').prop('checked'));
82
      assert.isTrue(tr3.hasClass('context-menu-selection'));
83
    });
84
  });
85

  
86
  suite('With shift key + left click, multiple rows can be selected ', async () => {
87

  
88
    let $rows;
89
    let addMultipleSelection;
90

  
91
    setup(async () => {
92
      $rows = $('.hascontextmenu');
93
      ({ addMultipleSelection } = await import('../../public/javascripts/context_menu.js'));
94
    });
95

  
96
    test('The last selected row is lower than the clicked', async () => {
97
      const selected = await addMultipleSelection($rows, $rows[5], $rows[0]);
98
      assert.equal(selected.length, 5);
99
    });
100

  
101
    test('The last selected row is above the clicked', async () => {
102
      const selected = await addMultipleSelection($rows, $rows[0], $rows[5]);
103
      assert.equal(selected.length, 5);
104
    });
105

  
106
    test('The last selected row is same as the clicked', async () => {
107
      const selected = await addMultipleSelection($rows, $rows[0], $rows[0]);
108
      assert.equal(selected.length, 0);
109
    });
110
  });
111

  
112
  suite('get context menu position', () => {
113

  
114
    let $element;
115
    let reverseRenderAction;
116
    let normalRenderAction;
117
    let reverseFolderAction;
118
    let normalFolderAction;
119

  
120
    setup(async () => {
121
      const div = await document.getElementById('menu');
122
      $element = $(div);
123
      ({ reverseRenderAction, normalRenderAction, reverseFolderAction, normalFolderAction } = await import('../../public/javascripts/context_menu.js'));
124
    });
125

  
126
    test('reverseRenderAction returns function to change DOM Element', () => {
127
      const obj = { render_pos: 100, menu_size: 50, position: 'left', class_name: 'reverse-x' }
128
      const action = reverseRenderAction(obj);
129
      action($element);
130
      assert.isTrue($element.hasClass('reverse-x'));
131
      assert.equal('50px', $element.css('left'));
132
    });
133

  
134
    test('normalRenderAction returns function to change DOM Element', () => {
135
      const obj = { render_pos: 100, menu_size: 50, position: 'left', class_name: 'reverse-x' }
136
      const action = normalRenderAction(obj);
137
      action($element);
138
      assert.isFalse($element.hasClass('reverse-x'));
139
      assert.equal('100px', $element.css('left'));
140
    });
141

  
142
    test('reverseFolderAction returns funtion to change submenu element', () => {
143
      const obj = { window_height: 100, mouse_y_c: 100 }
144
      const action = reverseFolderAction(obj)
145
      action($element);
146
      assert.isTrue($element.find('.folder').toArray().every(e => e.classList.contains('down')));
147
    });
148

  
149
    test('reverseFolderAction returns funtion that dont change submenu element', () => {
150
      const obj = { window_height: 100, mouse_y_c: 325 }
151
      const action = reverseFolderAction(obj)
152
      action($element);
153
      assert.isTrue($element.find('.folder').toArray().every(e => !e.classList.contains('down')));
154
    });
155

  
156
    test('normalFolderAction returns funtion to change submenu element', () => {
157
      const obj = { window_height: 100, mouse_y_c: 100 }
158
      const action = normalFolderAction(obj)
159
      action($element);
160
      assert.isTrue($element.find('.folder').toArray().every(e => e.classList.contains('up')));
161
    });
162

  
163
    test('normalFolderAction returns funtion that dont change submenu element', () => {
164
      const obj = { window_height: 445, mouse_y_c: 100 }
165
      const action = normalFolderAction(obj)
166
      action($element);
167
      assert.isTrue($element.find('.folder').toArray().every(e => !e.classList.contains('up')));
168
    });
169
  });
170
});
171

  
test/javascripts/helper.js
1
import path from 'path';
2
import { JSDOM } from 'jsdom';
3

  
4
export const prepare = (url, filename) => {
5
  const __dirname = path.dirname(new URL(url).pathname)
6
  const html = path.resolve(__dirname, filename)
7
  const options = {
8
    runScripts: "dangerously",
9
    resources:  "usable"
10
  }
11
  return JSDOM.fromFile(html, options);
12
}
(3-3/4)