-
Notifications
You must be signed in to change notification settings - Fork 286
/
ContextMenu.js
323 lines (299 loc) · 11.8 KB
/
ContextMenu.js
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
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
/****************************************************************************
** @license
** This demo file is part of yFiles for HTML 2.6.
** Copyright (c) 2000-2024 by yWorks GmbH, Vor dem Kreuzberg 28,
** 72070 Tuebingen, Germany. All rights reserved.
**
** yFiles demo files exhibit yFiles for HTML functionalities. Any redistribution
** of demo files in source code or binary form, with or without
** modification, is not permitted.
**
** Owners of a valid software license for a yFiles for HTML version that this
** demo is shipped with are allowed to use the demo source code as basis
** for their own yFiles for HTML powered applications. Use of such programs is
** governed by the rights and conditions as set out in the yFiles for HTML
** license agreement.
**
** THIS SOFTWARE IS PROVIDED ''AS IS'' AND ANY EXPRESS OR IMPLIED
** WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
** MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN
** NO EVENT SHALL yWorks BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
** SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
** TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
** PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
** LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
** NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
** SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
**
***************************************************************************/
import { GraphComponent, Point, TimeSpan } from 'yfiles'
import { BrowserDetection } from './BrowserDetection.js'
/**
* A demo implementation of a context menu that is used in various yFiles demos.
*
* The yFiles for HTML library can easily be used with any context menu implementation. For your project, we recommend to
* use the context menu that comes with your GUI framework, if any. However, feel free to re-use parts of this
* implementation in your project if it fits your needs.
*
* This context menu uses buttons as menu items and thus works with mouse, touch, and keyboard. Once a menu item is
* clicked and the menu closes, it gives the focus back to its graph component.
*
* You shouldn't create a new instance of this menu for each 'show'. Instead, clear its menu items and add new ones
* for the new show location.
*/
export class ContextMenu {
element
focusOutListener
focusInListener
closeListener
closeOnEscListener
blurredTimeout
isOpen
onClosedCallbackField
/**
* Creates a new empty menu.
*
* @param {!GraphComponent} graphComponent The graph component of this context menu.
*/
constructor(graphComponent) {
const contextMenu = document.createElement('div')
contextMenu.setAttribute('class', 'demo-context-menu')
this.element = contextMenu
this.blurredTimeout = null
this.isOpen = false
this.onClosedCallbackField = null
// Listeners for focus events since this menu closes itself if it loses the focus.
this.focusOutListener = (evt) => {
this.onFocusOut(evt.relatedTarget)
}
this.focusInListener = () => {
if (this.blurredTimeout) {
clearTimeout(this.blurredTimeout)
this.blurredTimeout = null
}
}
// A click listener that closes the menu and calls the onCloseCallback.
// This way, the individual menu items do not need to handle this by themselves.
this.closeListener = (evt) => {
evt.stopPropagation()
evt.preventDefault()
this.close()
// Set the focus to the graph component
graphComponent.focus()
this.onClosedCallback()
}
// An ESC key press listener that closes the menu and calls the callback.
this.closeOnEscListener = (evt) => {
if (evt.key === 'Escape' && this.element.parentNode) {
this.closeListener(evt)
}
}
}
/**
* Adds a new separator to this menu.
*/
addSeparator() {
const separator = document.createElement('div')
separator.setAttribute('class', 'demo-context-menu__separator')
this.element.appendChild(separator)
}
/**
* Adds a new menu entry with the given text and click-listener to this menu.
* @param {!string} label
* @param {?function} clickListener
* @returns {!HTMLElement}
*/
addMenuItem(label, clickListener) {
const menuItem = document.createElement('button')
menuItem.setAttribute('class', 'demo-context-menu__item')
menuItem.innerHTML = label
if (clickListener !== null) {
menuItem.addEventListener('click', clickListener, false)
}
this.element.appendChild(menuItem)
return menuItem
}
/**
* Removes all menu entries and separators from this menu.
*/
clearItems() {
const element = this.element
while (element.lastChild != null) {
element.removeChild(element.lastChild)
}
}
/**
* Shows this menu at the given location.
*
* This menu only shows if it has at least one menu item.
*
* @param {!Point} location The location of the menu relative to the left edge of the entire
* document. These are typically the pageX and pageY coordinates of the contextmenu event.
*/
show(location) {
if (this.element.childElementCount <= 0) {
return
}
this.element.addEventListener('focusout', this.focusOutListener)
this.element.addEventListener('focusin', this.focusInListener)
this.element.addEventListener('click', this.closeListener, false)
document.addEventListener('keydown', this.closeOnEscListener, false)
// Set the location of this menu and append it to the body
const style = this.element.style
style.setProperty('position', 'absolute', '')
style.setProperty('left', `${location.x}px`, '')
style.setProperty('top', `${location.y}px`, '')
if (document.fullscreenElement && !document.fullscreenElement.contains(document.body)) {
document.fullscreenElement.appendChild(this.element)
} else {
document.body.appendChild(this.element)
}
// trigger enter animation
setTimeout(() => {
this.element.classList.add('demo-context-menu--visible')
}, 0)
this.element.firstElementChild.focus()
this.isOpen = true
}
/**
* Closes this menu.
*/
close() {
this.element.removeEventListener('focusout', this.focusOutListener)
this.element.removeEventListener('focusin', this.focusInListener)
this.element.removeEventListener('click', this.closeListener, false)
document.removeEventListener('keydown', this.closeOnEscListener, false)
const parentNode = this.element.parentNode
if (parentNode) {
// trigger fade-out animation on a clone
const contextMenuClone = this.element.cloneNode(true)
contextMenuClone.classList.add('demo-context-menu--clone')
parentNode.appendChild(contextMenuClone)
// fade the clone out, then remove it from the DOM. Both actions need to be timed.
setTimeout(() => {
contextMenuClone.classList.remove('demo-context-menu--visible')
setTimeout(() => {
parentNode.removeChild(contextMenuClone)
}, 300)
}, 0)
this.element.classList.remove('demo-context-menu--visible')
parentNode.removeChild(this.element)
}
this.isOpen = false
}
/**
* Sets a callback function that is invoked if the context menu closed itself, for example because a
* menu item was clicked.
*
* Typically, the provided callback informs the {@link ContextMenuInputMode} that this menu is
* closed.
*/
get onClosedCallback() {
if (this.onClosedCallbackField == null) {
alert('For this context menu, the onClosedCallback property must be set.')
}
return this.onClosedCallbackField
}
/**
* @type {!function}
*/
set onClosedCallback(callback) {
this.onClosedCallbackField = callback
}
/**
* Adds event listeners for events that should show the context menu. These listeners then call the provided
* openingCallback function.
*
* Besides the obvious `contextmenu` event, we listen for long presses and the Context Menu key.
*
* A long touch press doesn't trigger a `contextmenu` event on all platforms therefore we listen to the
* GraphComponent's TouchLongPress event
*
* The Context Menu key is not handled correctly in Chrome. In other browsers, when the Context Menu key is
* pressed, the correct `contextmenu` event is fired but the event location is not meaningful.
* In this case, we set a better location, centered on the given element.
*
* @param {!GraphComponent} graphComponent The graph component of this context menu.
* @param {!function} openingCallback This function is called when an event that should
* open the context menu occurred. It gets the location of the event.
*/
addOpeningEventListeners(graphComponent, openingCallback) {
// Listen for the contextmenu event
// Note: On Linux based systems (e.g. Ubuntu), the contextmenu event is fired on mouse down
// which triggers the ContextMenuInputMode before the ClickInputMode. Therefore, handling the
// event will prevent the ItemRightClicked event from firing.
// For more information, see https://docs.yworks.com/yfileshtml/#/kb/article/780/
graphComponent.div.addEventListener(
'contextmenu',
(evt) => {
evt.preventDefault()
if (!this.isOpen) {
// might be open already because of the long press event listener
openingCallback(new Point(evt.pageX, evt.pageY))
}
},
false
)
if (BrowserDetection.safariVersion > 0 || BrowserDetection.iOSVersion > 0) {
// Additionally add a long press listener especially for iOS, since it does not fire the contextmenu event.
let contextMenuTimer
graphComponent.addTouchDownListener((_, evt) => {
if (!evt.device.isPrimaryDevice) {
// a second pointer is down, so just dismiss the context-menu event
clearTimeout(contextMenuTimer)
return
}
contextMenuTimer = window.setTimeout(() => {
const viewLocation = graphComponent.toViewCoordinates(evt.location)
openingCallback(graphComponent.toPageFromView(viewLocation))
}, 500)
})
graphComponent.addTouchUpListener(() => {
clearTimeout(contextMenuTimer)
})
}
// Listen to the context menu key to make it work in Chrome
graphComponent.div.addEventListener('keyup', (evt) => {
if (evt.key === 'ContextMenu') {
evt.preventDefault()
openingCallback(ContextMenu.getCenterInPage(graphComponent.div))
}
})
}
/**
* Closes the context menu when it lost the focus.
*
* @param {!HTMLElement} relatedTarget The related target of the focus event.
*/
onFocusOut(relatedTarget) {
// focusout can also occur when the focus shifts between the buttons in this context menu.
// We have to find out if none of the buttons has the focus and focusout is real
if (relatedTarget) {
if (relatedTarget.parentElement && relatedTarget.parentElement !== this.element) {
this.close()
}
} else if (!this.blurredTimeout) {
// If the browser doesn't provide a related target, we wait a little bit to see whether the focus is given to
// another button in this context menu
this.element.addEventListener('focusin', this.focusInListener)
this.blurredTimeout = setTimeout(() => {
this.close()
}, 350)
}
}
/**
* Calculates the location of the center of the given element in absolute coordinates relative to the body element.
* @param {!HTMLElement} element
* @returns {!Point}
*/
static getCenterInPage(element) {
let left = element.clientWidth / 2.0
let top = element.clientHeight / 2.0
while (element.offsetParent) {
left += element.offsetLeft
top += element.offsetTop
element = element.offsetParent
}
return new Point(left, top)
}
}