Skip to content

Commit 7582654

Browse files
authored
Merge pull request #5 from exoRift/dev
Release 1.0.0
2 parents e5f4e92 + ef525bd commit 7582654

25 files changed

+4361
-2813
lines changed

.github/workflows/quality_assurance.yml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,7 @@ name: Quality Assurance
22

33
on:
44
push:
5-
branches:
6-
- '*'
7-
- '!gh-pages'
85
pull_request:
9-
branches:
10-
- '*'
11-
- '!gh-pages'
126

137
jobs:
148
lint:

README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
**react-fluent-mobile** allows you to take your mobile browser's native features and augment them, improving gloss and agility without compromising on ability.
1717

1818
## Selecting text
19-
*INSERT GIF*
19+
<img alt='selectionvideo' src='assets/selection.gif' width='150'/>
2020

2121
Fluent takes selecting text on mobile to a whole new level by adding the *selection manipulation pad*. When text is selected by the user, whether selected through normal means, selected by the website, or tap-selected on Android, the *selection manipulation pad* appears. Users can touch and drag on the pad to shift the bounds of their selection in any direction they'd like, transforming their selection. Once the selection is fit to the user's liking, they can tap on the pad to instantly copy their selection to their clipboard.
2222

@@ -35,9 +35,18 @@ nativeManipulationInactivityDuration|The interval the manipulation pad is inacti
3535
theme|The theme of the pad (dark, light)
3636

3737
## Context menus
38-
*INSERT GIF*
38+
<img alt='contextvideo' src='assets/context.gif' width='150'/>
3939

40-
*Coming soon*
40+
Context menus have been reimagined! Now, instead of holding and lifting your finger four times, holding down on a link or image will launch a cleaner context menu in which you can drag you finger to the desired option and lift your finger to select it. No more tapping!
41+
42+
If the new context menu is not desired, there is an option located at the bottom corner of the screen to disable it.
43+
44+
> NOTE: The *share* features are only available on HTTPS sites
45+
46+
### Component Properties
47+
Name|Description
48+
-|-
49+
theme|The theme of the pad (dark, light)
4150

4251
## Media control
4352
*INSERT GIF*
@@ -76,7 +85,8 @@ function Component (props) {
7685
## Known bugs
7786
- Tapping on the manipulation pad on Safari makes the selection invisible (this is an unavoidable quirk with Safari)
7887
## Developer notes
79-
- FM works on all browsers and platforms
88+
- The share feature in the custom context menu doesn't work if the server is not HTTPS
89+
- Fluent Mobile works on all browsers and platforms
8090
- Safari does not allow haptics
8191
- The custom FlexibleRange class used for the selection system is exposed in the exports. Feel free to use it
8292
- Try to keep the mixins at the root of the heirarchy

assets/context.gif

3.47 MB
Loading

assets/selection.gif

2.08 MB
Loading

package-lock.json

Lines changed: 2615 additions & 2219 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-fluent-mobile",
3-
"version": "0.1.3",
3+
"version": "1.0.0",
44
"description": "A series of React mixin modules that augment the mobile user experience",
55
"main": "dist/index.js",
66
"module": "dist/index.esm.js",

src/components/ContextMixin.jsx

Lines changed: 247 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,299 @@
11
import React from 'react'
22
import PropTypes from 'prop-types'
33

4+
import {
5+
TouchHandler
6+
} from '../util/TouchHandler.js'
7+
import {
8+
options as menuOptions,
9+
optionsForTag
10+
} from '../util/menu-options.jsx'
11+
412
import '../styles/Context.css'
13+
import '../styles/notification.css'
514

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+
*/
618
class ContextMixin extends React.Component {
719
static propTypes = {
8-
holdDelay: PropTypes.number,
9-
holdTime: PropTypes.number
20+
/** The theme of the menus (dark, light) */
21+
theme: PropTypes.string
1022
}
1123

1224
static defaultProps = {
13-
holdDelay: 100,
14-
holdTime: 500
25+
theme: 'dark'
1526
}
1627

1728
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'
1939
}
2040

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+
2153
constructor (props) {
2254
super(props)
2355

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)
2758
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)
2864
}
2965

3066
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()
3574
}
3675

3776
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)
4286
}
4387

4488
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+
4599
return (
46100
<>
47101
{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>
48117
</>
49118
)
50119
}
51120

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+
})
55130

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+
}
57139
}
58140

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+
}
61157
}
62158

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'
67184

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+
}
69202
}
70203

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+
72264
this.setState({
73265
holding: false
74266
})
267+
}
75268

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()
77276
}
78277

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`
81297
}
82298
}
83299

0 commit comments

Comments
 (0)