feat: Swipe gestures on mobile have been improved, and are now enabled by default. You can turn them off from Preferences > General > Labs (#2321)

This commit is contained in:
Aman Harwara
2023-05-09 22:04:46 +05:30
committed by GitHub
parent 9b1a1510cf
commit e1bbff14b6
11 changed files with 231 additions and 120 deletions

View File

@@ -109,7 +109,6 @@
"dependencies": { "dependencies": {
"@ariakit/react": "^0.1.2", "@ariakit/react": "^0.1.2",
"@lexical/headless": "0.10.0", "@lexical/headless": "0.10.0",
"@radix-ui/react-slot": "^1.0.1", "@radix-ui/react-slot": "^1.0.1"
"contactjs": "2.1.5"
} }
} }

View File

@@ -14,7 +14,7 @@ const ListItemTags: FunctionComponent<Props> = ({ hideTags, tags }) => {
} }
return ( return (
<div className="mt-1.5 flex flex-wrap gap-2 text-sm lg:text-xs"> <div className="mt-1.5 flex flex-wrap gap-2 overflow-hidden text-sm lg:text-xs">
{tags.map((tag) => ( {tags.map((tag) => (
<span <span
className="inline-flex items-center rounded-sm bg-passive-4-opacity-variant py-1 px-1.5 text-foreground" className="inline-flex items-center rounded-sm bg-passive-4-opacity-variant py-1 px-1.5 text-foreground"

View File

@@ -74,7 +74,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
<div <div
ref={listItemRef} ref={listItemRef}
className={classNames( className={classNames(
'content-list-item text-tex flex w-full cursor-pointer items-stretch', 'content-list-item flex w-full cursor-pointer items-stretch text-text',
selected && `selected border-l-2 border-solid border-accessory-tint-${tint}`, selected && `selected border-l-2 border-solid border-accessory-tint-${tint}`,
isPreviousItemTiled && 'mt-3 border-t border-solid border-t-border', isPreviousItemTiled && 'mt-3 border-t border-solid border-t-border',
isNextItemTiled && 'mb-3 border-b border-solid border-b-border', isNextItemTiled && 'mb-3 border-b border-solid border-b-border',

View File

@@ -69,6 +69,7 @@ type State = {
syncTakingTooLong: boolean syncTakingTooLong: boolean
monospaceFont?: boolean monospaceFont?: boolean
plainEditorFocused?: boolean plainEditorFocused?: boolean
paneGestureEnabled?: boolean
updateSavingIndicator?: boolean updateSavingIndicator?: boolean
editorFeatureIdentifier?: string editorFeatureIdentifier?: string
@@ -676,6 +677,11 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
PrefDefaults[PrefKey.UpdateSavingStatusIndicator], PrefDefaults[PrefKey.UpdateSavingStatusIndicator],
) )
const paneGestureEnabled = this.application.getPreference(
PrefKey.PaneGesturesEnabled,
PrefDefaults[PrefKey.PaneGesturesEnabled],
)
await this.reloadSpellcheck() await this.reloadSpellcheck()
this.reloadLineWidth() this.reloadLineWidth()
@@ -683,6 +689,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.setState({ this.setState({
monospaceFont, monospaceFont,
updateSavingIndicator, updateSavingIndicator,
paneGestureEnabled,
}) })
reloadFont(monospaceFont) reloadFont(monospaceFont)
@@ -893,7 +900,8 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
ref={this.editorContentRef} ref={this.editorContentRef}
> >
{editorMode === 'component' && this.state.editorComponentViewer && ( {editorMode === 'component' && this.state.editorComponentViewer && (
<div className="component-view flex-grow"> <div className="component-view relative flex-grow">
{this.state.paneGestureEnabled && <div className="absolute top-0 left-0 h-full w-[20px] md:hidden" />}
<ComponentView <ComponentView
key={this.state.editorComponentViewer.identifier} key={this.state.editorComponentViewer.identifier}
componentViewer={this.state.editorComponentViewer} componentViewer={this.state.editorComponentViewer}

View File

@@ -1,9 +1,37 @@
import { useStateRef } from '@/Hooks/useStateRef' import { useStateRef } from '@/Hooks/useStateRef'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Direction, Pan, PointerListener, type GestureEventData } from 'contactjs'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { useApplication } from '../ApplicationProvider' import { useApplication } from '../ApplicationProvider'
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs' import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'
import { PrefDefaults } from '@/Constants/PrefDefaults'
function getScrollParent(node: HTMLElement | null): HTMLElement | null {
if (!node) {
return null
}
if (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth) {
return node
} else {
return getScrollParent(node.parentElement)
}
}
const supportsPassive = (() => {
let supportsPassive = false
try {
const opts = Object.defineProperty({}, 'passive', {
get: () => {
supportsPassive = true
},
})
window.addEventListener('test', null as never, opts)
window.removeEventListener('test', null as never, opts)
} catch (e) {
/* empty */
}
return supportsPassive
})()
export const usePaneSwipeGesture = ( export const usePaneSwipeGesture = (
direction: 'left' | 'right', direction: 'left' | 'right',
@@ -12,16 +40,21 @@ export const usePaneSwipeGesture = (
) => { ) => {
const application = useApplication() const application = useApplication()
const overlayElementRef = useRef<HTMLElement | null>(null) const underlayElementRef = useRef<HTMLElement | null>(null)
const [element, setElement] = useState<HTMLElement | null>(null) const [element, setElement] = useState<HTMLElement | null>(null)
const onSwipeEndRef = useStateRef(onSwipeEnd) const onSwipeEndRef = useStateRef(onSwipeEnd)
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm) const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const [isEnabled, setIsEnabled] = useState(() => application.getPreference(PrefKey.PaneGesturesEnabled, false)) const adjustedGesture = gesture === 'pan' && prefersReducedMotion ? 'swipe' : gesture
const [isEnabled, setIsEnabled] = useState(() =>
application.getPreference(PrefKey.PaneGesturesEnabled, PrefDefaults[PrefKey.PaneGesturesEnabled]),
)
useEffect(() => { useEffect(() => {
return application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => { return application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
setIsEnabled(application.getPreference(PrefKey.PaneGesturesEnabled, false)) setIsEnabled(application.getPreference(PrefKey.PaneGesturesEnabled, PrefDefaults[PrefKey.PaneGesturesEnabled]))
}) })
}, [application]) }, [application])
@@ -38,128 +71,201 @@ export const usePaneSwipeGesture = (
return return
} }
const panRecognizer = new Pan(element, { underlayElementRef.current = element.parentElement?.querySelector(`[data-pane-underlay="${element.id}"]`) || null
supportedDirections: direction === 'left' ? [Direction.Left] : [Direction.Right],
})
const pointerListener = new PointerListener(element, { let startX = 0
supportedGestures: [panRecognizer], let clientX = 0
}) let closestScrollContainer: HTMLElement | null
let scrollContainerAxis: 'x' | 'y' | null = null
let canceled = false
function onPan(e: unknown) { const TouchMoveThreshold = 25
const event = e as CustomEvent<GestureEventData> const TouchStartThreshold = direction === 'right' ? 25 : window.innerWidth - 25
if (!element) { const SwipeFinishThreshold = window.innerWidth / 2.5
const scrollListener = (event: Event) => {
canceled = true
setTimeout(() => {
if ((event.target as HTMLElement).style.overflowY === 'hidden') {
canceled = false
}
}, 5)
}
const touchStartListener = (event: TouchEvent) => {
scrollContainerAxis = null
canceled = false
const touch = event.touches[0]
startX = touch.clientX
if (
(direction === 'right' && startX > TouchStartThreshold) ||
(direction === 'left' && startX < TouchStartThreshold)
) {
canceled = true
return return
} }
const x = event.detail.global.deltaX closestScrollContainer = getScrollParent(event.target as HTMLElement)
requestElementUpdate(x) if (closestScrollContainer) {
} closestScrollContainer.addEventListener('scroll', scrollListener, supportsPassive ? { passive: true } : false)
let ticking = false if (closestScrollContainer.scrollWidth > closestScrollContainer.clientWidth) {
scrollContainerAxis = 'x'
function onPanEnd(e: unknown) {
const event = e as CustomEvent<GestureEventData>
if (ticking) {
setTimeout(function () {
onPanEnd(event)
}, 100)
} else {
if (!element) {
return
}
if (direction === 'right' && event.detail.global.deltaX > 40) {
onSwipeEndRef.current(element)
} else if (direction === 'left' && event.detail.global.deltaX < -40) {
onSwipeEndRef.current(element)
} else {
requestElementUpdate(0)
}
if (overlayElementRef.current) {
overlayElementRef.current
.animate([{ opacity: 0 }], {
duration: 5,
fill: 'forwards',
})
.finished.then(() => {
if (overlayElementRef.current) {
overlayElementRef.current.remove()
overlayElementRef.current = null
}
})
.catch(console.error)
} }
} }
element.style.willChange = 'transform'
} }
function requestElementUpdate(x: number) { const updateElement = (x: number) => {
if (!ticking) { if (!underlayElementRef.current) {
requestAnimationFrame(function () { const underlayElement = document.createElement('div')
if (!element) { underlayElement.style.position = 'fixed'
return underlayElement.style.top = '0'
} underlayElement.style.left = '0'
underlayElement.style.width = '100%'
underlayElement.style.height = '100%'
underlayElement.style.pointerEvents = 'none'
if (adjustedGesture === 'pan') {
underlayElement.style.backgroundColor = '#000'
} else {
underlayElement.style.background =
direction === 'right'
? 'linear-gradient(to right, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0))'
: 'linear-gradient(to left, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0))'
}
underlayElement.style.opacity = '0'
underlayElement.style.willChange = 'opacity'
underlayElement.setAttribute('role', 'presentation')
underlayElement.ariaHidden = 'true'
underlayElement.setAttribute('data-pane-underlay', element.id)
if (!overlayElementRef.current) { if (adjustedGesture === 'pan') {
const overlayElement = document.createElement('div') element.before(underlayElement)
overlayElement.style.position = 'fixed' } else {
overlayElement.style.top = '0' element.after(underlayElement)
overlayElement.style.left = '0' }
overlayElement.style.width = '100%' underlayElementRef.current = underlayElement
overlayElement.style.height = '100%' }
overlayElement.style.pointerEvents = 'none'
overlayElement.style.backgroundColor = '#000'
overlayElement.style.opacity = '0'
overlayElement.style.willChange = 'opacity'
element.before(overlayElement) if (adjustedGesture === 'pan') {
overlayElementRef.current = overlayElement element.animate(
} [
{
const newLeft = direction === 'right' ? Math.max(x, 0) : Math.min(x, 0) transform: `translate3d(${x}px, 0, 0)`,
element.animate([{ transform: `translate3d(${newLeft}px,0,0)` }], { duration: 0, fill: 'forwards' }) },
],
const percent = Math.min(window.innerWidth / newLeft / 10, 0.45) {
overlayElementRef.current.animate([{ opacity: percent }], {
duration: 0, duration: 0,
fill: 'forwards', fill: 'forwards',
}) },
)
ticking = false
})
ticking = true
} }
const percent =
adjustedGesture === 'pan'
? Math.min(window.innerWidth / Math.abs(x) / 10, 0.65)
: Math.min(Math.abs(x) / 100, 0.65)
underlayElementRef.current.animate([{ opacity: percent }], {
duration: 0,
fill: 'forwards',
})
} }
if (gesture === 'pan') { const touchMoveListener = (event: TouchEvent) => {
element.addEventListener('panleft', onPan) if (scrollContainerAxis === 'x') {
element.addEventListener('panright', onPan) return
element.addEventListener('panend', onPanEnd) }
} else {
if (direction === 'left') { if (canceled) {
element.addEventListener('swipeleft', onPanEnd) return
}
const touch = event.touches[0]
clientX = touch.clientX
const deltaX = clientX - startX
if (Math.abs(deltaX) < TouchMoveThreshold) {
return
}
if (closestScrollContainer && closestScrollContainer.style.overflowY !== 'hidden') {
closestScrollContainer.style.overflowY = 'hidden'
}
if (adjustedGesture === 'pan') {
const x =
direction === 'right' ? Math.max(deltaX - TouchMoveThreshold, 0) : Math.min(deltaX + TouchMoveThreshold, 0)
updateElement(x)
} else { } else {
element.addEventListener('swiperight', onPanEnd) const x = direction === 'right' ? Math.max(deltaX, 0) : Math.min(deltaX, 0)
updateElement(x)
} }
} }
const disposeUnderlay = () => {
if (!underlayElementRef.current) {
return
}
underlayElementRef.current
.animate([{ opacity: 0 }], {
easing: 'cubic-bezier(.36,.66,.04,1)',
duration: 500,
fill: 'forwards',
})
.finished.then(() => {
if (underlayElementRef.current) {
underlayElementRef.current.remove()
underlayElementRef.current = null
}
})
.catch(console.error)
}
const touchEndListener = () => {
if (closestScrollContainer) {
closestScrollContainer.removeEventListener('scroll', scrollListener)
closestScrollContainer.style.overflowY = ''
}
if (canceled) {
updateElement(0)
disposeUnderlay()
return
}
const deltaX = clientX - startX
element.style.willChange = ''
if (
(direction === 'right' && deltaX > SwipeFinishThreshold) ||
(direction === 'left' && deltaX < -SwipeFinishThreshold)
) {
onSwipeEndRef.current(element)
} else {
updateElement(0)
}
disposeUnderlay()
}
element.addEventListener('touchstart', touchStartListener, supportsPassive ? { passive: true } : false)
element.addEventListener('touchmove', touchMoveListener, supportsPassive ? { passive: true } : false)
element.addEventListener('touchend', touchEndListener, supportsPassive ? { passive: true } : false)
return () => { return () => {
pointerListener.destroy() element.removeEventListener('touchstart', touchStartListener)
if (gesture === 'pan') { element.removeEventListener('touchmove', touchMoveListener)
element.removeEventListener('panleft', onPan) element.removeEventListener('touchend', touchEndListener)
element.removeEventListener('panright', onPan) disposeUnderlay()
element.removeEventListener('panend', onPanEnd)
} else {
if (direction === 'left') {
element.removeEventListener('swipeleft', onPanEnd)
} else {
element.removeEventListener('swiperight', onPanEnd)
}
}
} }
}, [direction, element, gesture, isMobileScreen, onSwipeEndRef, isEnabled]) }, [direction, element, isMobileScreen, onSwipeEndRef, isEnabled, adjustedGesture])
return [setElement] return [setElement]
} }

View File

@@ -8,6 +8,7 @@ import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegmen
import LabsFeature from './LabsFeature' import LabsFeature from './LabsFeature'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator' import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { PrefDefaults } from '@/Constants/PrefDefaults'
type ExperimentalFeatureItem = { type ExperimentalFeatureItem = {
identifier: FeatureIdentifier identifier: FeatureIdentifier
@@ -30,11 +31,13 @@ const LabsPane: FunctionComponent<Props> = ({ application }) => {
const [experimentalFeatures, setExperimentalFeatures] = useState<ExperimentalFeatureItem[]>([]) const [experimentalFeatures, setExperimentalFeatures] = useState<ExperimentalFeatureItem[]>([])
const [isPaneGesturesEnabled, setIsPaneGesturesEnabled] = useState(() => const [isPaneGesturesEnabled, setIsPaneGesturesEnabled] = useState(() =>
application.getPreference(PrefKey.PaneGesturesEnabled, false), application.getPreference(PrefKey.PaneGesturesEnabled, PrefDefaults[PrefKey.PaneGesturesEnabled]),
) )
useEffect(() => { useEffect(() => {
return application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => { return application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
setIsPaneGesturesEnabled(application.getPreference(PrefKey.PaneGesturesEnabled, false)) setIsPaneGesturesEnabled(
application.getPreference(PrefKey.PaneGesturesEnabled, PrefDefaults[PrefKey.PaneGesturesEnabled]),
)
}) })
}, [application]) }, [application])

View File

@@ -37,4 +37,5 @@ export const PrefDefaults = {
[PrefKey.CustomNoteTitleFormat]: 'YYYY-MM-DD [at] hh:mm A', [PrefKey.CustomNoteTitleFormat]: 'YYYY-MM-DD [at] hh:mm A',
[PrefKey.UpdateSavingStatusIndicator]: true, [PrefKey.UpdateSavingStatusIndicator]: true,
[PrefKey.DarkMode]: false, [PrefKey.DarkMode]: false,
[PrefKey.PaneGesturesEnabled]: true,
} as const } as const

View File

@@ -12,6 +12,9 @@ export class PhotoRecorder {
constructor() {} constructor() {}
public static async isSupported(): Promise<boolean> { public static async isSupported(): Promise<boolean> {
if (!navigator.mediaDevices) {
return false
}
const devices = await navigator.mediaDevices.enumerateDevices() const devices = await navigator.mediaDevices.enumerateDevices()
const hasCamera = devices.some((device) => device.kind === 'videoinput') const hasCamera = devices.some((device) => device.kind === 'videoinput')
return hasCamera return hasCamera

View File

@@ -76,8 +76,7 @@ module.exports = (env) => {
* Exclude all node_modules, except for those we need to run through our babel rules because * Exclude all node_modules, except for those we need to run through our babel rules because
* they may contain class properties and other ES6+ syntax. * they may contain class properties and other ES6+ syntax.
*/ */
exclude: exclude: /node_modules\/(?!(@standardnotes\/common|@standardnotes\/domain-core|webextension-polyfill))/,
/node_modules\/(?!(@standardnotes\/common|@standardnotes\/domain-core|contactjs|webextension-polyfill))/,
use: [ use: [
'babel-loader', 'babel-loader',
{ {

View File

@@ -5548,7 +5548,6 @@ __metadata:
autoprefixer: ^10.4.13 autoprefixer: ^10.4.13
babel-loader: ^9.1.0 babel-loader: ^9.1.0
circular-dependency-plugin: ^5.2.2 circular-dependency-plugin: ^5.2.2
contactjs: 2.1.5
copy-webpack-plugin: ^11.0.0 copy-webpack-plugin: ^11.0.0
css-loader: "*" css-loader: "*"
dayjs: ^1.11.7 dayjs: ^1.11.7
@@ -9632,13 +9631,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"contactjs@npm:2.1.5":
version: 2.1.5
resolution: "contactjs@npm:2.1.5"
checksum: 5be3d66835e5a78a16abe6cdbf0c2f4a44471086e313ef8550c6747026cca9e7ff5144fdaaf6b4307cb296baf0fd394ca63758354e58099f1fde3c6756a390c3
languageName: node
linkType: hard
"content-disposition@npm:0.5.4": "content-disposition@npm:0.5.4":
version: 0.5.4 version: 0.5.4
resolution: "content-disposition@npm:0.5.4" resolution: "content-disposition@npm:0.5.4"