Skip to content

Commit

Permalink
Merge pull request #5 from exoRift/dev
Browse files Browse the repository at this point in the history
Release 1.0.0
  • Loading branch information
exoRift authored Nov 23, 2022
2 parents e5f4e92 + ef525bd commit 7582654
Show file tree
Hide file tree
Showing 25 changed files with 4,361 additions and 2,813 deletions.
6 changes: 0 additions & 6 deletions .github/workflows/quality_assurance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@ name: Quality Assurance

on:
push:
branches:
- '*'
- '!gh-pages'
pull_request:
branches:
- '*'
- '!gh-pages'

jobs:
lint:
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
**react-fluent-mobile** allows you to take your mobile browser's native features and augment them, improving gloss and agility without compromising on ability.

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

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.

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

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

*Coming soon*
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!

If the new context menu is not desired, there is an option located at the bottom corner of the screen to disable it.

> NOTE: The *share* features are only available on HTTPS sites
### Component Properties
Name|Description
-|-
theme|The theme of the pad (dark, light)

## Media control
*INSERT GIF*
Expand Down Expand Up @@ -76,7 +85,8 @@ function Component (props) {
## Known bugs
- Tapping on the manipulation pad on Safari makes the selection invisible (this is an unavoidable quirk with Safari)
## Developer notes
- FM works on all browsers and platforms
- The share feature in the custom context menu doesn't work if the server is not HTTPS
- Fluent Mobile works on all browsers and platforms
- Safari does not allow haptics
- The custom FlexibleRange class used for the selection system is exposed in the exports. Feel free to use it
- Try to keep the mixins at the root of the heirarchy
Expand Down
Binary file added assets/context.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/selection.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4,834 changes: 2,615 additions & 2,219 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-fluent-mobile",
"version": "0.1.3",
"version": "1.0.0",
"description": "A series of React mixin modules that augment the mobile user experience",
"main": "dist/index.js",
"module": "dist/index.esm.js",
Expand Down
278 changes: 247 additions & 31 deletions src/components/ContextMixin.jsx
Original file line number Diff line number Diff line change
@@ -1,83 +1,299 @@
import React from 'react'
import PropTypes from 'prop-types'

import {
TouchHandler
} from '../util/TouchHandler.js'
import {
options as menuOptions,
optionsForTag
} from '../util/menu-options.jsx'

import '../styles/Context.css'
import '../styles/notification.css'

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

static defaultProps = {
holdDelay: 100,
holdTime: 500
theme: 'dark'
}

state = {
holding: false
/** Whether a touch has been detected and the custom context menu is mounted */
initialized: false,
/** Whether the custom context menu is disabled by the user or not */
disabled: false,
/** If the context menu is active and being held */
holding: false,
/** The currently held tag */
holdingTag: null,
/** Which side of the screen the touch origin is on */
side: 'right'
}

/** The element the context menu is being emitted from */
holdingElement = null
/** The index of the tag option the user is hovering over */
hoveringIndex = 0
/** How long the mixin should wait before checking for overflow */
overflowTimeoutDuration = 0
/** The timeout to check if the context menu is overflowing */
overflowTimeout = null

/** A ref to the context menu element */
menu = React.createRef()

constructor (props) {
super(props)

this.cancelEvent = this.cancelEvent.bind(this)
this.touchstart = this.touchstart.bind(this)
this.stopTimer = this.stopTimer.bind(this)
this.initializeComponent = this.initializeComponent.bind(this)
this.prepareContextMenu = this.prepareContextMenu.bind(this)
this.launchContextMenu = this.launchContextMenu.bind(this)
this.closeContextMenu = this.closeContextMenu.bind(this)
this.cancelContextMenu = this.cancelContextMenu.bind(this)
this.switchHovering = this.switchHovering.bind(this)
this.disable = this.disable.bind(this)
this.rectifyOverflow = this.rectifyOverflow.bind(this)
}

componentDidMount () {
document.addEventListener('touchstart', this.touchstart)
document.addEventListener('touchend', this.stopTimer)
document.addEventListener('touchmove', this.stopTimer)
document.addEventListener('contextmenu', this.cancelEvent)
document.addEventListener('touchstart', this.initializeComponent, {
once: true
})
document.addEventListener('touchstart', this.prepareContextMenu)
document.addEventListener('touchcancel', this.cancelContextMenu)

if (window.FLUENT_IS_IOS) TouchHandler.mount()
}

componentWillUnmount () {
document.removeEventListener('touchstart', this.touchstart)
document.removeEventListener('touchend', this.stopTimer)
document.removeEventListener('touchmove', this.stopTimer)
document.removeEventListener('contextmenu', this.cancelEvent)
if (this.state.initialized && !this.state.disabled) {
document.removeEventListener('contextmenu', this.launchContextMenu)
document.removeEventListener('touchmove', this.switchHovering)
document.removeEventListener('touchend', this.closeContextMenu)
document.removeEventListener('touchstart', this.prepareContextMenu)
document.removeEventListener('touchcancel', this.cancelContextMenu)

if (window.FLUENT_IS_IOS) TouchHandler.unmount()
} else document.removeEventListener('touchstart', this.initializeComponent)
}

render () {
if (!this.state.initialized) return null

if (this.state.disabled) {
return (
<>
<i className={`fluent notification ${this.props.theme}`}>Now using the native context menu</i>
</>
)
}

return (
<>
{this.props.children}

<div
className={`fluent menu ${this.props.theme} ${this.state.holding ? 'active' : 'inactive'} ${this.state.side}`}
id='fluentmenu'
ref={this.menu}
>
<div className='fluent menubody'>
{menuOptions.empty.Component}
{optionsForTag[this.state.holdingTag]?.map?.((o, i) =>
<React.Fragment key={i}>{o.Component}</React.Fragment>
)}
</div>

{menuOptions.disable.Component}
</div>
</>
)
}

cancelEvent (e) {
e.preventDefault()
e.stopPropagation()
/**
* Mount the component and listen for context menu events
* @fires document#touchstart
*/
initializeComponent () {
if (!this.state.initialized) {
this.setState({
initialized: true
})

return false
setImmediate(() => {
const style = window.getComputedStyle(this.menu.current.getElementsByClassName('menubody')[0])

this.overflowTimeoutDuration = parseFloat(style.transitionDuration) * 1000
})

document.addEventListener('contextmenu', this.launchContextMenu)
}
}

touchstart (e) {
if (!this.state.holding && (e.target instanceof HTMLImageElement || e.target.getAttribute('href'))) this.startTimer()
/**
* Prepare the context menu elements to be revealed
* @param {TouchEvent} e The touch event
* @fires document#touchstart
*/
prepareContextMenu (e) {
if (!this.state.holding) {
let tag = e.target.tagName.toLowerCase()
if (tag === 'img' && e.target.parentElement.tagName.toLowerCase() === 'a') tag = 'aimg'

if (tag in optionsForTag) {
this.setState({
holdingTag: tag
})
} else if (this.state.holdingTag) this.setState({ holdingTag: null })
}
}

startTimer () {
this.setState({
holding: true
})
/**
* Open the context menu
* @param {MouseEvent} e The contextmenu event
* @fires document#contextmenu
*/
launchContextMenu (e) {
if (this.state.holdingTag in optionsForTag) {
e.preventDefault()

const side = e.clientX >= (window.innerWidth / 2) ? 'right' : 'left'

switch (side) {
case 'left':
this.menu.current.style.paddingRight = ''
this.menu.current.style.paddingLeft = e.clientX + 'px'

break
case 'right':
this.menu.current.style.paddingRight = (window.innerWidth - e.clientX) + 'px'
this.menu.current.style.paddingLeft = ''

break
default: break
}
this.menu.current.style.paddingTop = e.clientY + 'px'

this.timeout = setTimeout(this.launchContextMenu, this.props.holdTime)
this.holdingElement = e.target
this.hoveringIndex = 0
this.setState({
holding: true,
side
})

this.overflowTimeout = setTimeout(this.rectifyOverflow, this.overflowTimeoutDuration)

navigator?.vibrate?.(1)

document.addEventListener('touchmove', this.switchHovering)

document.addEventListener('touchend', this.closeContextMenu, {
once: true
})
}
}

stopTimer () {
/**
* Switch the index of the context menu option the user is hovering
* @param {TouchEvent} e The touch event
* @fires document#touchmove
*/
switchHovering (e) {
const [touch] = e.changedTouches

const options = this.menu.current.querySelectorAll('.menuoption, .menudivider')

for (let o = options.length - 1; o >= 0; o--) {
if (!options[o].classList.contains('menuoption')) continue

const rect = options[o].getBoundingClientRect()

if ((touch.clientY >= rect.top || !o)) {
if (o !== this.hoveringIndex) {
options[this.hoveringIndex]?.classList?.remove?.('hovering')

// Play blob animation
options[this.hoveringIndex]?.classList?.remove?.('blob')
setImmediate(() => options[this.hoveringIndex]?.classList?.add?.('blob'))

options[o].classList.add('hovering')

// Play blob animation
options[o].classList.remove('blob')
setImmediate(() => options[o].classList.add('blob'))

this.hoveringIndex = o

navigator.vibrate?.(20)
}

break
}
}
}

/**
* Close the context menu and execute the hovering option
*/
closeContextMenu () {
const tagOptions = optionsForTag[this.state.holdingTag]
const body = this.menu.current.getElementsByClassName('menubody')[0]

document.removeEventListener('touchmove', this.switchHovering)

this.overflowTimeout = clearTimeout(this.rectifyOverflow)
body.style.marginLeft = ''
body.style.marginRight = ''
body.style.marginTop = ''

this.menu.current.getElementsByClassName('menuoption hovering')[0]?.classList?.remove?.('hovering')

// 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)
if (this.hoveringIndex > tagOptions.length) menuOptions.disable.action(this.holdingElement, this)
// Subtract 1 from index to accomodate the blank button
else if (this.hoveringIndex) tagOptions[this.hoveringIndex - 1]?.action?.(this.holdingElement, this)

this.setState({
holding: false
})
}

this.timeout = clearTimeout(this.timeout)
/**
* Close the context menu without triggering any actions
*/
cancelContextMenu () {
this.hoveringIndex = 0

this.closeContextMenu()
}

launchContextMenu (e) {
navigator?.vibrate?.(1)
/**
* Disable the context menu
*/
disable () {
this.componentWillUnmount()

this.setState({
disabled: true
})
}

rectifyOverflow () {
const body = this.menu.current.getElementsByClassName('menubody')[0]
const rect = body.getBoundingClientRect()

if (rect.left < 0) body.style.marginRight = `${rect.left}px`
else if (rect.right > window.innerWidth) body.style.marginLeft = `${window.innerWidth - rect.right}px`

if (rect.bottom > window.innerHeight) body.style.marginTop = `${window.innerHeight - rect.bottom}px`
}
}

Expand Down
Loading

0 comments on commit 7582654

Please sign in to comment.