|
1 | 1 | import React from 'react' |
2 | 2 | import PropTypes from 'prop-types' |
3 | 3 |
|
| 4 | +import { |
| 5 | + TouchHandler |
| 6 | +} from '../util/TouchHandler.js' |
| 7 | +import { |
| 8 | + options as menuOptions, |
| 9 | + optionsForTag |
| 10 | +} from '../util/menu-options.jsx' |
| 11 | + |
4 | 12 | import '../styles/Context.css' |
| 13 | +import '../styles/notification.css' |
5 | 14 |
|
| 15 | +/** |
| 16 | + * This is a mixin that augments the the experience of opening context menu for mobile users in a way that reduces the lift-count for actions |
| 17 | + */ |
6 | 18 | class ContextMixin extends React.Component { |
7 | 19 | static propTypes = { |
8 | | - holdDelay: PropTypes.number, |
9 | | - holdTime: PropTypes.number |
| 20 | + /** The theme of the menus (dark, light) */ |
| 21 | + theme: PropTypes.string |
10 | 22 | } |
11 | 23 |
|
12 | 24 | static defaultProps = { |
13 | | - holdDelay: 100, |
14 | | - holdTime: 500 |
| 25 | + theme: 'dark' |
15 | 26 | } |
16 | 27 |
|
17 | 28 | state = { |
18 | | - holding: false |
| 29 | + /** Whether a touch has been detected and the custom context menu is mounted */ |
| 30 | + initialized: false, |
| 31 | + /** Whether the custom context menu is disabled by the user or not */ |
| 32 | + disabled: false, |
| 33 | + /** If the context menu is active and being held */ |
| 34 | + holding: false, |
| 35 | + /** The currently held tag */ |
| 36 | + holdingTag: null, |
| 37 | + /** Which side of the screen the touch origin is on */ |
| 38 | + side: 'right' |
19 | 39 | } |
20 | 40 |
|
| 41 | + /** The element the context menu is being emitted from */ |
| 42 | + holdingElement = null |
| 43 | + /** The index of the tag option the user is hovering over */ |
| 44 | + hoveringIndex = 0 |
| 45 | + /** How long the mixin should wait before checking for overflow */ |
| 46 | + overflowTimeoutDuration = 0 |
| 47 | + /** The timeout to check if the context menu is overflowing */ |
| 48 | + overflowTimeout = null |
| 49 | + |
| 50 | + /** A ref to the context menu element */ |
| 51 | + menu = React.createRef() |
| 52 | + |
21 | 53 | constructor (props) { |
22 | 54 | super(props) |
23 | 55 |
|
24 | | - this.cancelEvent = this.cancelEvent.bind(this) |
25 | | - this.touchstart = this.touchstart.bind(this) |
26 | | - this.stopTimer = this.stopTimer.bind(this) |
| 56 | + this.initializeComponent = this.initializeComponent.bind(this) |
| 57 | + this.prepareContextMenu = this.prepareContextMenu.bind(this) |
27 | 58 | this.launchContextMenu = this.launchContextMenu.bind(this) |
| 59 | + this.closeContextMenu = this.closeContextMenu.bind(this) |
| 60 | + this.cancelContextMenu = this.cancelContextMenu.bind(this) |
| 61 | + this.switchHovering = this.switchHovering.bind(this) |
| 62 | + this.disable = this.disable.bind(this) |
| 63 | + this.rectifyOverflow = this.rectifyOverflow.bind(this) |
28 | 64 | } |
29 | 65 |
|
30 | 66 | componentDidMount () { |
31 | | - document.addEventListener('touchstart', this.touchstart) |
32 | | - document.addEventListener('touchend', this.stopTimer) |
33 | | - document.addEventListener('touchmove', this.stopTimer) |
34 | | - document.addEventListener('contextmenu', this.cancelEvent) |
| 67 | + document.addEventListener('touchstart', this.initializeComponent, { |
| 68 | + once: true |
| 69 | + }) |
| 70 | + document.addEventListener('touchstart', this.prepareContextMenu) |
| 71 | + document.addEventListener('touchcancel', this.cancelContextMenu) |
| 72 | + |
| 73 | + if (window.FLUENT_IS_IOS) TouchHandler.mount() |
35 | 74 | } |
36 | 75 |
|
37 | 76 | componentWillUnmount () { |
38 | | - document.removeEventListener('touchstart', this.touchstart) |
39 | | - document.removeEventListener('touchend', this.stopTimer) |
40 | | - document.removeEventListener('touchmove', this.stopTimer) |
41 | | - document.removeEventListener('contextmenu', this.cancelEvent) |
| 77 | + if (this.state.initialized && !this.state.disabled) { |
| 78 | + document.removeEventListener('contextmenu', this.launchContextMenu) |
| 79 | + document.removeEventListener('touchmove', this.switchHovering) |
| 80 | + document.removeEventListener('touchend', this.closeContextMenu) |
| 81 | + document.removeEventListener('touchstart', this.prepareContextMenu) |
| 82 | + document.removeEventListener('touchcancel', this.cancelContextMenu) |
| 83 | + |
| 84 | + if (window.FLUENT_IS_IOS) TouchHandler.unmount() |
| 85 | + } else document.removeEventListener('touchstart', this.initializeComponent) |
42 | 86 | } |
43 | 87 |
|
44 | 88 | render () { |
| 89 | + if (!this.state.initialized) return null |
| 90 | + |
| 91 | + if (this.state.disabled) { |
| 92 | + return ( |
| 93 | + <> |
| 94 | + <i className={`fluent notification ${this.props.theme}`}>Now using the native context menu</i> |
| 95 | + </> |
| 96 | + ) |
| 97 | + } |
| 98 | + |
45 | 99 | return ( |
46 | 100 | <> |
47 | 101 | {this.props.children} |
| 102 | + |
| 103 | + <div |
| 104 | + className={`fluent menu ${this.props.theme} ${this.state.holding ? 'active' : 'inactive'} ${this.state.side}`} |
| 105 | + id='fluentmenu' |
| 106 | + ref={this.menu} |
| 107 | + > |
| 108 | + <div className='fluent menubody'> |
| 109 | + {menuOptions.empty.Component} |
| 110 | + {optionsForTag[this.state.holdingTag]?.map?.((o, i) => |
| 111 | + <React.Fragment key={i}>{o.Component}</React.Fragment> |
| 112 | + )} |
| 113 | + </div> |
| 114 | + |
| 115 | + {menuOptions.disable.Component} |
| 116 | + </div> |
48 | 117 | </> |
49 | 118 | ) |
50 | 119 | } |
51 | 120 |
|
52 | | - cancelEvent (e) { |
53 | | - e.preventDefault() |
54 | | - e.stopPropagation() |
| 121 | + /** |
| 122 | + * Mount the component and listen for context menu events |
| 123 | + * @fires document#touchstart |
| 124 | + */ |
| 125 | + initializeComponent () { |
| 126 | + if (!this.state.initialized) { |
| 127 | + this.setState({ |
| 128 | + initialized: true |
| 129 | + }) |
55 | 130 |
|
56 | | - return false |
| 131 | + setImmediate(() => { |
| 132 | + const style = window.getComputedStyle(this.menu.current.getElementsByClassName('menubody')[0]) |
| 133 | + |
| 134 | + this.overflowTimeoutDuration = parseFloat(style.transitionDuration) * 1000 |
| 135 | + }) |
| 136 | + |
| 137 | + document.addEventListener('contextmenu', this.launchContextMenu) |
| 138 | + } |
57 | 139 | } |
58 | 140 |
|
59 | | - touchstart (e) { |
60 | | - if (!this.state.holding && (e.target instanceof HTMLImageElement || e.target.getAttribute('href'))) this.startTimer() |
| 141 | + /** |
| 142 | + * Prepare the context menu elements to be revealed |
| 143 | + * @param {TouchEvent} e The touch event |
| 144 | + * @fires document#touchstart |
| 145 | + */ |
| 146 | + prepareContextMenu (e) { |
| 147 | + if (!this.state.holding) { |
| 148 | + let tag = e.target.tagName.toLowerCase() |
| 149 | + if (tag === 'img' && e.target.parentElement.tagName.toLowerCase() === 'a') tag = 'aimg' |
| 150 | + |
| 151 | + if (tag in optionsForTag) { |
| 152 | + this.setState({ |
| 153 | + holdingTag: tag |
| 154 | + }) |
| 155 | + } else if (this.state.holdingTag) this.setState({ holdingTag: null }) |
| 156 | + } |
61 | 157 | } |
62 | 158 |
|
63 | | - startTimer () { |
64 | | - this.setState({ |
65 | | - holding: true |
66 | | - }) |
| 159 | + /** |
| 160 | + * Open the context menu |
| 161 | + * @param {MouseEvent} e The contextmenu event |
| 162 | + * @fires document#contextmenu |
| 163 | + */ |
| 164 | + launchContextMenu (e) { |
| 165 | + if (this.state.holdingTag in optionsForTag) { |
| 166 | + e.preventDefault() |
| 167 | + |
| 168 | + const side = e.clientX >= (window.innerWidth / 2) ? 'right' : 'left' |
| 169 | + |
| 170 | + switch (side) { |
| 171 | + case 'left': |
| 172 | + this.menu.current.style.paddingRight = '' |
| 173 | + this.menu.current.style.paddingLeft = e.clientX + 'px' |
| 174 | + |
| 175 | + break |
| 176 | + case 'right': |
| 177 | + this.menu.current.style.paddingRight = (window.innerWidth - e.clientX) + 'px' |
| 178 | + this.menu.current.style.paddingLeft = '' |
| 179 | + |
| 180 | + break |
| 181 | + default: break |
| 182 | + } |
| 183 | + this.menu.current.style.paddingTop = e.clientY + 'px' |
67 | 184 |
|
68 | | - this.timeout = setTimeout(this.launchContextMenu, this.props.holdTime) |
| 185 | + this.holdingElement = e.target |
| 186 | + this.hoveringIndex = 0 |
| 187 | + this.setState({ |
| 188 | + holding: true, |
| 189 | + side |
| 190 | + }) |
| 191 | + |
| 192 | + this.overflowTimeout = setTimeout(this.rectifyOverflow, this.overflowTimeoutDuration) |
| 193 | + |
| 194 | + navigator?.vibrate?.(1) |
| 195 | + |
| 196 | + document.addEventListener('touchmove', this.switchHovering) |
| 197 | + |
| 198 | + document.addEventListener('touchend', this.closeContextMenu, { |
| 199 | + once: true |
| 200 | + }) |
| 201 | + } |
69 | 202 | } |
70 | 203 |
|
71 | | - stopTimer () { |
| 204 | + /** |
| 205 | + * Switch the index of the context menu option the user is hovering |
| 206 | + * @param {TouchEvent} e The touch event |
| 207 | + * @fires document#touchmove |
| 208 | + */ |
| 209 | + switchHovering (e) { |
| 210 | + const [touch] = e.changedTouches |
| 211 | + |
| 212 | + const options = this.menu.current.querySelectorAll('.menuoption, .menudivider') |
| 213 | + |
| 214 | + for (let o = options.length - 1; o >= 0; o--) { |
| 215 | + if (!options[o].classList.contains('menuoption')) continue |
| 216 | + |
| 217 | + const rect = options[o].getBoundingClientRect() |
| 218 | + |
| 219 | + if ((touch.clientY >= rect.top || !o)) { |
| 220 | + if (o !== this.hoveringIndex) { |
| 221 | + options[this.hoveringIndex]?.classList?.remove?.('hovering') |
| 222 | + |
| 223 | + // Play blob animation |
| 224 | + options[this.hoveringIndex]?.classList?.remove?.('blob') |
| 225 | + setImmediate(() => options[this.hoveringIndex]?.classList?.add?.('blob')) |
| 226 | + |
| 227 | + options[o].classList.add('hovering') |
| 228 | + |
| 229 | + // Play blob animation |
| 230 | + options[o].classList.remove('blob') |
| 231 | + setImmediate(() => options[o].classList.add('blob')) |
| 232 | + |
| 233 | + this.hoveringIndex = o |
| 234 | + |
| 235 | + navigator.vibrate?.(20) |
| 236 | + } |
| 237 | + |
| 238 | + break |
| 239 | + } |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + /** |
| 244 | + * Close the context menu and execute the hovering option |
| 245 | + */ |
| 246 | + closeContextMenu () { |
| 247 | + const tagOptions = optionsForTag[this.state.holdingTag] |
| 248 | + const body = this.menu.current.getElementsByClassName('menubody')[0] |
| 249 | + |
| 250 | + document.removeEventListener('touchmove', this.switchHovering) |
| 251 | + |
| 252 | + this.overflowTimeout = clearTimeout(this.rectifyOverflow) |
| 253 | + body.style.marginLeft = '' |
| 254 | + body.style.marginRight = '' |
| 255 | + body.style.marginTop = '' |
| 256 | + |
| 257 | + this.menu.current.getElementsByClassName('menuoption hovering')[0]?.classList?.remove?.('hovering') |
| 258 | + |
| 259 | + // Disable button which is not a part of the options list (This can be > instead of >= since 1 is added to the index on account of the blank option) |
| 260 | + if (this.hoveringIndex > tagOptions.length) menuOptions.disable.action(this.holdingElement, this) |
| 261 | + // Subtract 1 from index to accomodate the blank button |
| 262 | + else if (this.hoveringIndex) tagOptions[this.hoveringIndex - 1]?.action?.(this.holdingElement, this) |
| 263 | + |
72 | 264 | this.setState({ |
73 | 265 | holding: false |
74 | 266 | }) |
| 267 | + } |
75 | 268 |
|
76 | | - this.timeout = clearTimeout(this.timeout) |
| 269 | + /** |
| 270 | + * Close the context menu without triggering any actions |
| 271 | + */ |
| 272 | + cancelContextMenu () { |
| 273 | + this.hoveringIndex = 0 |
| 274 | + |
| 275 | + this.closeContextMenu() |
77 | 276 | } |
78 | 277 |
|
79 | | - launchContextMenu (e) { |
80 | | - navigator?.vibrate?.(1) |
| 278 | + /** |
| 279 | + * Disable the context menu |
| 280 | + */ |
| 281 | + disable () { |
| 282 | + this.componentWillUnmount() |
| 283 | + |
| 284 | + this.setState({ |
| 285 | + disabled: true |
| 286 | + }) |
| 287 | + } |
| 288 | + |
| 289 | + rectifyOverflow () { |
| 290 | + const body = this.menu.current.getElementsByClassName('menubody')[0] |
| 291 | + const rect = body.getBoundingClientRect() |
| 292 | + |
| 293 | + if (rect.left < 0) body.style.marginRight = `${rect.left}px` |
| 294 | + else if (rect.right > window.innerWidth) body.style.marginLeft = `${window.innerWidth - rect.right}px` |
| 295 | + |
| 296 | + if (rect.bottom > window.innerHeight) body.style.marginTop = `${window.innerHeight - rect.bottom}px` |
81 | 297 | } |
82 | 298 | } |
83 | 299 |
|
|
0 commit comments