feat: Swipe gestures on mobile are now enabled by default and have been improved. You can disable them from Preferences > General > Labs (#2319)
This commit is contained in:
Binary file not shown.
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,18 @@ 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 [isEnabled, setIsEnabled] = useState(() => application.getPreference(PrefKey.PaneGesturesEnabled, false))
|
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,126 +68,159 @@ 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 = 15
|
||||||
const event = e as CustomEvent<GestureEventData>
|
const SwipeFinishThreshold = 40 + TouchMoveThreshold
|
||||||
if (!element) {
|
|
||||||
|
const scrollListener = () => {
|
||||||
|
canceled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchStartListener = (event: TouchEvent) => {
|
||||||
|
closestScrollContainer = getScrollParent(event.target as HTMLElement)
|
||||||
|
if (closestScrollContainer) {
|
||||||
|
closestScrollContainer.addEventListener('scroll', scrollListener)
|
||||||
|
|
||||||
|
if (closestScrollContainer.scrollWidth > closestScrollContainer.clientWidth) {
|
||||||
|
scrollContainerAxis = 'x'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scrollContainerAxis = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = event.touches[0]
|
||||||
|
startX = touch.clientX
|
||||||
|
|
||||||
|
canceled = false
|
||||||
|
|
||||||
|
element.style.willChange = 'transform'
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateElement = (x: number) => {
|
||||||
|
if (!underlayElementRef.current) {
|
||||||
|
const underlayElement = document.createElement('div')
|
||||||
|
underlayElement.style.position = 'fixed'
|
||||||
|
underlayElement.style.top = '0'
|
||||||
|
underlayElement.style.left = '0'
|
||||||
|
underlayElement.style.width = '100%'
|
||||||
|
underlayElement.style.height = '100%'
|
||||||
|
underlayElement.style.pointerEvents = 'none'
|
||||||
|
underlayElement.style.backgroundColor = '#000'
|
||||||
|
underlayElement.style.opacity = '0'
|
||||||
|
underlayElement.style.willChange = 'opacity'
|
||||||
|
underlayElement.setAttribute('role', 'presentation')
|
||||||
|
underlayElement.ariaHidden = 'true'
|
||||||
|
underlayElement.setAttribute('data-pane-underlay', element.id)
|
||||||
|
|
||||||
|
element.before(underlayElement)
|
||||||
|
underlayElementRef.current = underlayElement
|
||||||
|
}
|
||||||
|
|
||||||
|
element.animate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
transform: `translate3d(${x}px, 0, 0)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: 0,
|
||||||
|
fill: 'forwards',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const percent = Math.min(window.innerWidth / x / 10, 0.45)
|
||||||
|
underlayElementRef.current.animate([{ opacity: percent }], {
|
||||||
|
duration: 0,
|
||||||
|
fill: 'forwards',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchMoveListener = (event: TouchEvent) => {
|
||||||
|
if (scrollContainerAxis === 'x') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const x = event.detail.global.deltaX
|
if (canceled) {
|
||||||
requestElementUpdate(x)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let ticking = false
|
const touch = event.touches[0]
|
||||||
|
clientX = touch.clientX
|
||||||
|
|
||||||
function onPanEnd(e: unknown) {
|
const deltaX = clientX - startX
|
||||||
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) {
|
if (Math.abs(deltaX) < TouchMoveThreshold) {
|
||||||
onSwipeEndRef.current(element)
|
return
|
||||||
} else if (direction === 'left' && event.detail.global.deltaX < -40) {
|
}
|
||||||
onSwipeEndRef.current(element)
|
|
||||||
} else {
|
|
||||||
requestElementUpdate(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overlayElementRef.current) {
|
if (closestScrollContainer) {
|
||||||
overlayElementRef.current
|
closestScrollContainer.style.touchAction = 'none'
|
||||||
.animate([{ opacity: 0 }], {
|
}
|
||||||
duration: 5,
|
|
||||||
fill: 'forwards',
|
const x =
|
||||||
})
|
direction === 'right' ? Math.max(deltaX - TouchMoveThreshold, 0) : Math.min(deltaX + TouchMoveThreshold, 0)
|
||||||
.finished.then(() => {
|
|
||||||
if (overlayElementRef.current) {
|
if (gesture === 'pan') {
|
||||||
overlayElementRef.current.remove()
|
updateElement(x)
|
||||||
overlayElementRef.current = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestElementUpdate(x: number) {
|
const touchEndListener = () => {
|
||||||
if (!ticking) {
|
if (closestScrollContainer) {
|
||||||
requestAnimationFrame(function () {
|
closestScrollContainer.removeEventListener('scroll', scrollListener)
|
||||||
if (!element) {
|
closestScrollContainer.style.touchAction = ''
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!overlayElementRef.current) {
|
if (canceled) {
|
||||||
const overlayElement = document.createElement('div')
|
updateElement(0)
|
||||||
overlayElement.style.position = 'fixed'
|
return
|
||||||
overlayElement.style.top = '0'
|
}
|
||||||
overlayElement.style.left = '0'
|
|
||||||
overlayElement.style.width = '100%'
|
|
||||||
overlayElement.style.height = '100%'
|
|
||||||
overlayElement.style.pointerEvents = 'none'
|
|
||||||
overlayElement.style.backgroundColor = '#000'
|
|
||||||
overlayElement.style.opacity = '0'
|
|
||||||
overlayElement.style.willChange = 'opacity'
|
|
||||||
|
|
||||||
element.before(overlayElement)
|
const deltaX = clientX - startX
|
||||||
overlayElementRef.current = overlayElement
|
|
||||||
}
|
|
||||||
|
|
||||||
const newLeft = direction === 'right' ? Math.max(x, 0) : Math.min(x, 0)
|
element.style.willChange = ''
|
||||||
element.animate([{ transform: `translate3d(${newLeft}px,0,0)` }], { duration: 0, fill: 'forwards' })
|
|
||||||
|
|
||||||
const percent = Math.min(window.innerWidth / newLeft / 10, 0.45)
|
if (
|
||||||
overlayElementRef.current.animate([{ opacity: percent }], {
|
(direction === 'right' && deltaX > SwipeFinishThreshold) ||
|
||||||
duration: 0,
|
(direction === 'left' && deltaX < -SwipeFinishThreshold)
|
||||||
|
) {
|
||||||
|
onSwipeEndRef.current(element)
|
||||||
|
} else {
|
||||||
|
updateElement(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (underlayElementRef.current) {
|
||||||
|
underlayElementRef.current
|
||||||
|
.animate([{ opacity: 0 }], {
|
||||||
|
easing: 'cubic-bezier(.36,.66,.04,1)',
|
||||||
|
duration: 500,
|
||||||
fill: 'forwards',
|
fill: 'forwards',
|
||||||
})
|
})
|
||||||
|
.finished.then(() => {
|
||||||
ticking = false
|
if (underlayElementRef.current) {
|
||||||
})
|
underlayElementRef.current.remove()
|
||||||
|
underlayElementRef.current = null
|
||||||
ticking = true
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gesture === 'pan') {
|
element.addEventListener('touchstart', touchStartListener, supportsPassive ? { passive: true } : false)
|
||||||
element.addEventListener('panleft', onPan)
|
element.addEventListener('touchmove', touchMoveListener, supportsPassive ? { passive: true } : false)
|
||||||
element.addEventListener('panright', onPan)
|
element.addEventListener('touchend', touchEndListener, supportsPassive ? { passive: true } : false)
|
||||||
element.addEventListener('panend', onPanEnd)
|
|
||||||
} else {
|
|
||||||
if (direction === 'left') {
|
|
||||||
element.addEventListener('swipeleft', onPanEnd)
|
|
||||||
} else {
|
|
||||||
element.addEventListener('swiperight', onPanEnd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
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, gesture, isMobileScreen, onSwipeEndRef, isEnabled])
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -29,4 +29,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5547,7 +5547,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
|
||||||
@@ -9631,13 +9630,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"
|
||||||
|
|||||||
Reference in New Issue
Block a user