From 818c3066ccd92156dfc8c797074672cfb08de7bd Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Wed, 17 Aug 2022 20:20:18 +0530 Subject: [PATCH] fix: context menu on longpress on ios safari (#1405) --- .../ContentListView/FileListItem.tsx | 13 ++-- .../ContentListView/NoteListItem.tsx | 13 ++-- .../javascripts/Hooks/useContextMenuEvent.tsx | 38 ++++++++++++ .../src/javascripts/Hooks/useLongPress.tsx | 59 +++++++++++++++++++ packages/web/src/javascripts/Utils/Utils.ts | 10 ++-- 5 files changed, 119 insertions(+), 14 deletions(-) create mode 100644 packages/web/src/javascripts/Hooks/useContextMenuEvent.tsx create mode 100644 packages/web/src/javascripts/Hooks/useLongPress.tsx diff --git a/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx b/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx index 1788f187a..87cc0c00d 100644 --- a/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/FileListItem.tsx @@ -1,6 +1,6 @@ import { FileItem } from '@standardnotes/snjs' import { observer } from 'mobx-react-lite' -import { FunctionComponent, useCallback } from 'react' +import { FunctionComponent, useCallback, useRef } from 'react' import { getFileIconComponent } from '../AttachedFilesPopover/getFileIconComponent' import ListItemConflictIndicator from './ListItemConflictIndicator' import ListItemFlagIcons from './ListItemFlagIcons' @@ -9,6 +9,7 @@ import ListItemMetadata from './ListItemMetadata' import { DisplayableListItemProps } from './Types/DisplayableListItemProps' import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider' import { AppPaneId } from '../ResponsivePane/AppPaneMetadata' +import { useContextMenuEvent } from '@/Hooks/useContextMenuEvent' const FileListItem: FunctionComponent = ({ application, @@ -24,8 +25,11 @@ const FileListItem: FunctionComponent = ({ }) => { const { toggleAppPane } = useResponsiveAppPane() + const listItemRef = useRef(null) + const openFileContextMenu = useCallback( (posX: number, posY: number) => { + filesController.setShowFileContextMenu(false) filesController.setFileContextMenuLocation({ x: posX, y: posY, @@ -66,17 +70,16 @@ const FileListItem: FunctionComponent = ({ 'w-5 h-5 flex-shrink-0', ) + useContextMenuEvent(listItemRef, openContextMenu) + return (
{ - event.preventDefault() - void openContextMenu(event.clientX, event.clientY) - }} > {!hideIcon ? (
diff --git a/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx b/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx index 589a8d178..a53f6dead 100644 --- a/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx @@ -1,7 +1,7 @@ import { PLAIN_EDITOR_NAME } from '@/Constants/Constants' import { sanitizeHtmlString, SNNote } from '@standardnotes/snjs' import { observer } from 'mobx-react-lite' -import { FunctionComponent, useCallback } from 'react' +import { FunctionComponent, useCallback, useRef } from 'react' import Icon from '@/Components/Icon/Icon' import ListItemConflictIndicator from './ListItemConflictIndicator' import ListItemFlagIcons from './ListItemFlagIcons' @@ -10,6 +10,7 @@ import ListItemMetadata from './ListItemMetadata' import { DisplayableListItemProps } from './Types/DisplayableListItemProps' import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider' import { AppPaneId } from '../ResponsivePane/AppPaneMetadata' +import { useContextMenuEvent } from '@/Hooks/useContextMenuEvent' const NoteListItem: FunctionComponent = ({ application, @@ -26,12 +27,15 @@ const NoteListItem: FunctionComponent = ({ }) => { const { toggleAppPane } = useResponsiveAppPane() + const listItemRef = useRef(null) + const editorForNote = application.componentManager.editorForNote(item as SNNote) const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME const [icon, tint] = application.iconsController.getIconAndTintForNoteType(editorForNote?.package_info.note_type) const hasFiles = application.items.getFilesForNote(item as SNNote).length > 0 const openNoteContextMenu = (posX: number, posY: number) => { + notesController.setContextMenuOpen(false) notesController.setContextMenuClickLocation({ x: posX, y: posY, @@ -62,17 +66,16 @@ const NoteListItem: FunctionComponent = ({ } }, [item.uuid, selectionController, toggleAppPane]) + useContextMenuEvent(listItemRef, openContextMenu) + return (
{ - event.preventDefault() - void openContextMenu(event.clientX, event.clientY) - }} > {!hideIcon ? (
diff --git a/packages/web/src/javascripts/Hooks/useContextMenuEvent.tsx b/packages/web/src/javascripts/Hooks/useContextMenuEvent.tsx new file mode 100644 index 000000000..fead61e1f --- /dev/null +++ b/packages/web/src/javascripts/Hooks/useContextMenuEvent.tsx @@ -0,0 +1,38 @@ +import { isIOS } from '@/Utils' +import { RefObject, useCallback, useEffect } from 'react' +import { useLongPressEvent } from './useLongPress' + +export const useContextMenuEvent = (elementRef: RefObject, listener: (x: number, y: number) => void) => { + const { attachEvents, cleanupEvents } = useLongPressEvent(elementRef, listener) + + const handleContextMenuEvent = useCallback( + (event: MouseEvent) => { + event.preventDefault() + listener(event.clientX, event.clientY) + }, + [listener], + ) + + useEffect(() => { + const element = elementRef.current + + if (!element) { + return + } + + const shouldUseLongPress = isIOS() + + element.addEventListener('contextmenu', handleContextMenuEvent) + + if (shouldUseLongPress) { + attachEvents() + } + + return () => { + element.removeEventListener('contextmenu', handleContextMenuEvent) + if (shouldUseLongPress) { + cleanupEvents() + } + } + }, [attachEvents, cleanupEvents, elementRef, handleContextMenuEvent, listener]) +} diff --git a/packages/web/src/javascripts/Hooks/useLongPress.tsx b/packages/web/src/javascripts/Hooks/useLongPress.tsx new file mode 100644 index 000000000..8b5a841f2 --- /dev/null +++ b/packages/web/src/javascripts/Hooks/useLongPress.tsx @@ -0,0 +1,59 @@ +import { RefObject, useCallback, useMemo, useRef } from 'react' + +// According to https://reactnative.dev/docs/touchablewithoutfeedback#onlongpress +const ReactNativeLongpressDelay = 370 + +export const useLongPressEvent = ( + elementRef: RefObject, + listener: (x: number, y: number) => void, + delay = ReactNativeLongpressDelay, +) => { + const longPressTimeout = useRef() + + const clearLongPressTimeout = useCallback(() => { + if (longPressTimeout.current) { + clearTimeout(longPressTimeout.current) + } + }, []) + + const createLongPressTimeout = useCallback( + (event: PointerEvent) => { + clearLongPressTimeout() + longPressTimeout.current = window.setTimeout(() => { + const x = event.clientX + const y = event.clientY + + listener(x, y) + }, delay) + }, + [clearLongPressTimeout, delay, listener], + ) + + const attachEvents = useCallback(() => { + if (!elementRef.current) { + return + } + + elementRef.current.addEventListener('pointerdown', createLongPressTimeout) + elementRef.current.addEventListener('pointercancel', clearLongPressTimeout) + }, [clearLongPressTimeout, createLongPressTimeout, elementRef]) + + const cleanupEvents = useCallback(() => { + if (!elementRef.current) { + return + } + + elementRef.current.removeEventListener('pointerdown', createLongPressTimeout) + elementRef.current.removeEventListener('pointercancel', clearLongPressTimeout) + }, [clearLongPressTimeout, createLongPressTimeout, elementRef]) + + const memoizedReturn = useMemo( + () => ({ + attachEvents, + cleanupEvents, + }), + [attachEvents, cleanupEvents], + ) + + return memoizedReturn +} diff --git a/packages/web/src/javascripts/Utils/Utils.ts b/packages/web/src/javascripts/Utils/Utils.ts index 5d2a27c23..d3b605081 100644 --- a/packages/web/src/javascripts/Utils/Utils.ts +++ b/packages/web/src/javascripts/Utils/Utils.ts @@ -172,6 +172,11 @@ export const convertStringifiedBooleanToBoolean = (value: string) => { return value !== 'false' } +// https://stackoverflow.com/questions/9038625/detect-if-device-is-ios/9039885#9039885 +export const isIOS = () => + (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream) || + (navigator.userAgent.includes('Mac') && 'ontouchend' in document && navigator.maxTouchPoints > 1) + // https://stackoverflow.com/a/57527009/2504429 export const disableIosTextFieldZoom = () => { const addMaximumScaleToMetaViewport = () => { @@ -194,10 +199,7 @@ export const disableIosTextFieldZoom = () => { } } - // https://stackoverflow.com/questions/9038625/detect-if-device-is-ios/9039885#9039885 - const checkIsIOS = () => /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream - - if (checkIsIOS()) { + if (isIOS()) { addMaximumScaleToMetaViewport() } }