diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.css b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.css index 21ad1759..eec17ded 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.css +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.css @@ -3,7 +3,7 @@ * or the screen reader users as the popup behavior hint * Inspired by https://gist.github.com/ffoodd/000b59f431e3e64e4ce1a24d5bb36034 */ -.popup-close-message { +.r6o-popup-sr-only { border: 0 !important; clip: rect(1px, 1px, 1px, 1px); -webkit-clip-path: inset(50%); @@ -17,8 +17,8 @@ white-space: nowrap; } -.popup-close-message:focus, -.popup-close-message:active { +.r6o-popup-sr-only:focus, +.r6o-popup-sr-only:active { clip: auto; -webkit-clip-path: none; clip-path: none; diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx index d001400e..59170bc4 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx @@ -1,4 +1,7 @@ -import React, { PointerEvent, ReactNode, useCallback, useEffect, useState } from 'react'; +import { PointerEvent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { useAnnotator, useSelection } from '@annotorious/react'; +import type { TextAnnotation, TextAnnotator } from '@recogito/text-annotator'; +import { isMobile } from './isMobile'; import { autoUpdate, flip, @@ -13,13 +16,12 @@ import { useRole } from '@floating-ui/react'; -import { useAnnotator, useSelection } from '@annotorious/react'; -import type { TextAnnotation, TextAnnotator } from '@recogito/text-annotator'; - import './TextAnnotatorPopup.css'; interface TextAnnotationPopupProps { + ariaCloseWarning?: string; + popup(props: TextAnnotationPopupContentProps): ReactNode; } @@ -39,24 +41,18 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { const r = useAnnotator<TextAnnotator>(); const { selected, event } = useSelection<TextAnnotation>(); + const annotation = selected[0]?.annotation; const [isOpen, setOpen] = useState(selected?.length > 0); - const handleClose = () => { - r?.cancelSelected(); - }; - const { refs, floatingStyles, update, context } = useFloating({ - placement: 'top', + placement: isMobile() ? 'bottom' : 'top', open: isOpen, onOpenChange: (open, _event, reason) => { - setOpen(open); - - if (!open) { - if (reason === 'escape-key' || reason === 'focus-out') { - r?.cancelSelected(); - } + if (!open && (reason === 'escape-key' || reason === 'focus-out')) { + setOpen(open); + r?.cancelSelected(); } }, middleware: [ @@ -69,26 +65,20 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { }); const dismiss = useDismiss(context); + const role = useRole(context, { role: 'dialog' }); - const { getFloatingProps } = useInteractions([dismiss, role]); - const selectedKey = selected.map(a => a.annotation.id).join('-'); - useEffect(() => { - // Ignore all selection changes except those accompanied by a user event. - if (selected.length > 0 && event) { - setOpen(event.type === 'pointerup' || event.type === 'keydown'); - } - }, [selectedKey, event]); + const { getFloatingProps } = useInteractions([dismiss, role]); useEffect(() => { - // Close the popup if the selection is cleared - if (selected.length === 0 && isOpen) { - setOpen(false); - } - }, [isOpen, selectedKey]); + setOpen(selected.length > 0); + }, [selected.map(a => a.annotation.id).join('-')]); useEffect(() => { if (isOpen && annotation) { + // Extra precaution - shouldn't normally happen + if (!annotation.target.selector || annotation.target.selector.length < 1) return; + const { target: { selector: [{ range }] @@ -96,21 +86,14 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { } = annotation; refs.setPositionReference({ - getBoundingClientRect: range.getBoundingClientRect.bind(range), - getClientRects: range.getClientRects.bind(range) + getBoundingClientRect: () => range.getBoundingClientRect(), + getClientRects: () => range.getClientRects() }); } else { - // Don't leave the reference depending on the previously selected annotation refs.setPositionReference(null); } }, [isOpen, annotation, refs]); - // Prevent text-annotator from handling the irrelevant events triggered from the popup - const getStopEventsPropagationProps = useCallback( - () => ({ onPointerUp: (event: PointerEvent<HTMLDivElement>) => event.stopPropagation() }), - [] - ); - useEffect(() => { const config: MutationObserverInit = { attributes: true, childList: true, subtree: true }; @@ -125,21 +108,27 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { }; }, [update]); + // Prevent text-annotator from handling the irrelevant events triggered from the popup + const getStopEventsPropagationProps = useCallback( + () => ({ onPointerUp: (event: PointerEvent<HTMLDivElement>) => event.stopPropagation() }), + [] + ); + + // Don't shift focus to the floating element if selected via keyboard or on mobile. + const initialFocus = useMemo(() => { + return (event?.type === 'keyup' || event?.type === 'contextmenu' || isMobile()) ? -1 : 0; + }, [event]); + + const onClose = () => r?.cancelSelected(); + return isOpen && selected.length > 0 ? ( <FloatingPortal> <FloatingFocusManager context={context} modal={false} closeOnFocusOut={true} - initialFocus={ - /** - * Don't shift focus to the floating element - * when the selection performed with the keyboard - */ - event?.type === 'keydown' ? -1 : 0 - } returnFocus={false} - > + initialFocus={initialFocus}> <div className="annotation-popup text-annotation-popup not-annotatable" ref={refs.setFloating} @@ -152,13 +141,12 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { event })} - {/* It lets keyboard/sr users to know that the dialog closes when they focus out of it */} - <button className="popup-close-message" onClick={handleClose}> - This dialog closes when you leave it. + <button className="r6o-popup-sr-only" aria-live="assertive" onClick={onClose}> + {props.ariaCloseWarning || 'Click or leave this dialog to close it.'} </button> </div> </FloatingFocusManager> </FloatingPortal> ) : null; -}; +} \ No newline at end of file diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/isMobile.ts b/packages/text-annotator-react/src/TextAnnotatorPopup/isMobile.ts new file mode 100644 index 00000000..2cbde7c3 --- /dev/null +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/isMobile.ts @@ -0,0 +1,17 @@ +// https://stackoverflow.com/questions/21741841/detecting-ios-android-operating-system +export const isMobile = () => { + // @ts-ignore + var userAgent: string = navigator.userAgent || navigator.vendor || window.opera; + + if (/android/i.test(userAgent)) + return true; + + // @ts-ignore + // Note: as of recently, this NO LONGER DETECTS FIREFOX ON iOS! + // This means FF/iOS will behave like on the desktop, and loose + // selection handlebars after the popup opens. + if (/iPad|iPhone/.test(userAgent) && !window.MSStream) + return true; + + return false; +} \ No newline at end of file diff --git a/packages/text-annotator-react/test/App.tsx b/packages/text-annotator-react/test/App.tsx index d638e588..74849e2c 100644 --- a/packages/text-annotator-react/test/App.tsx +++ b/packages/text-annotator-react/test/App.tsx @@ -1,6 +1,6 @@ import React, { FC, useCallback, useEffect } from 'react'; -import { AnnotationBody, Annotorious, useAnnotationStore, useAnnotator, useSelection } from '@annotorious/react'; -import { TextAnnotator, TextAnnotatorPopup, type TextAnnotationPopupContentProps } from '../src'; +import { AnnotationBody, Annotorious, useAnnotationStore, useAnnotator } from '@annotorious/react'; +import { TextAnnotationPopupContentProps, TextAnnotator, TextAnnotatorPopup } from '../src'; import { W3CTextFormat, type TextAnnotation, type TextAnnotator as RecogitoTextAnnotator } from '@recogito/text-annotator'; const TestPopup: FC<TextAnnotationPopupContentProps> = (props) => { diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 164c4ed7..801e0f1c 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -10,6 +10,7 @@ import { debounce, splitAnnotatableRanges, rangeToSelector, + isMac, isWhitespaceOrEmpty, trimRangeToContainer, NOT_ANNOTATABLE_SELECTOR @@ -19,10 +20,11 @@ const CLICK_TIMEOUT = 300; const ARROW_KEYS = ['up', 'down', 'left', 'right']; +const SELECT_ALL = isMac ? '⌘+a' : 'ctrl+a'; + const SELECTION_KEYS = [ ...ARROW_KEYS.map(key => `shift+${key}`), - 'ctrl+a', - '⌘+a' + SELECT_ALL ]; export const SelectionHandler = ( @@ -48,7 +50,11 @@ export const SelectionHandler = ( let lastDownEvent: Selection['event'] | undefined; + let isContextMenuOpen = false; + const onSelectStart = (evt: Event) => { + isContextMenuOpen = false; + if (isLeftClick === false) return; @@ -59,7 +65,6 @@ export const SelectionHandler = ( * Note that Chrome/iOS will sometimes return the root doc as target! */ const annotatable = !(evt.target as Node).parentElement?.closest(NOT_ANNOTATABLE_SELECTOR); - currentTarget = annotatable ? { annotation: uuidv4(), selector: [], @@ -92,15 +97,11 @@ export const SelectionHandler = ( // Chrome/iOS does not reliably fire the 'selectstart' event! onSelectStart(lastDownEvent || evt); - } else if (sel.isCollapsed && timeDifference < CLICK_TIMEOUT) { - /* - Firefox doesn't fire the 'selectstart' when user clicks - over the text, which collapses the selection - */ + // Firefox doesn't fire the 'selectstart' when user clicks + // over the text, which collapses the selection onSelectStart(lastDownEvent || evt); - } } @@ -142,21 +143,12 @@ export const SelectionHandler = ( updated: new Date() }; + /** + * During mouse selection on the desktop, annotation won't usually exist while the selection is being edited. + * But it will be typical during keyboard or mobile handlebars selection! + */ if (store.getAnnotation(currentTarget.annotation)) { store.updateTarget(currentTarget, Origin.LOCAL); - } else { - // Proper lifecycle management: clear selection first... - selection.clear(); - - // ...then add annotation to store... - store.addAnnotation({ - id: currentTarget.annotation, - bodies: [], - target: currentTarget - }); - - // ...then make the new annotation the current selection - selection.userSelect(currentTarget.annotation, lastDownEvent); } }); @@ -166,6 +158,8 @@ export const SelectionHandler = ( * to the initial pointerdown event and remember the button */ const onPointerDown = (evt: PointerEvent) => { + if (isContextMenuOpen) return; + const annotatable = !(evt.target as Node).parentElement?.closest(NOT_ANNOTATABLE_SELECTOR); if (!annotatable) return; @@ -177,7 +171,23 @@ export const SelectionHandler = ( isLeftClick = lastDownEvent.button === 0; }; + // Helper + const upsertCurrentTarget = () => { + const exists = store.getAnnotation(currentTarget.annotation); + if (exists) { + store.updateTarget(currentTarget); + } else { + store.addAnnotation({ + id: currentTarget.annotation, + bodies: [], + target: currentTarget + }); + } + } + const onPointerUp = (evt: PointerEvent) => { + if (isContextMenuOpen) return; + const annotatable = !(evt.target as Node).parentElement?.closest(NOT_ANNOTATABLE_SELECTOR); if (!annotatable || !isLeftClick) return; @@ -193,8 +203,9 @@ export const SelectionHandler = ( if (hovered) { const { selected } = selection; - if (selected.length !== 1 || selected[0].id !== hovered.id) + if (selected.length !== 1 || selected[0].id !== hovered.id) { selection.userSelect(hovered.id, evt); + } } else if (!selection.isEmpty()) { selection.clear(); } @@ -212,23 +223,87 @@ export const SelectionHandler = ( * @see https://github.com/recogito/text-annotator-js/issues/136 */ setTimeout(() => { - const sel = document.getSelection() + const sel = document.getSelection(); // Just a click, not a selection if (sel?.isCollapsed && timeDifference < CLICK_TIMEOUT) { currentTarget = undefined; clickSelect(); - } else if (currentTarget && store.getAnnotation(currentTarget.annotation)) { - selection.userSelect(currentTarget.annotation, evt); + } else if (currentTarget && currentTarget.selector.length > 0) { + selection.clear(); + upsertCurrentTarget(); + selection.userSelect(currentTarget.annotation, clonePointerEvent(evt)); } }); } - hotkeys(SELECTION_KEYS.join(','), { element: container, keydown: true, keyup: false }, (evt) => { + const onContextMenu = (evt: PointerEvent) => { + isContextMenuOpen = true; + + const sel = document.getSelection(); + + if (sel?.isCollapsed) return; + + // When selecting the initial word, Chrome Android fires `contextmenu` + // before selectionChanged. + if (!currentTarget || currentTarget.selector.length === 0) { + onSelectionChange(evt); + } + + upsertCurrentTarget(); + + selection.userSelect(currentTarget.annotation, clonePointerEvent(evt)); + } + + const onKeyup = (evt: KeyboardEvent) => { + if (evt.key === 'Shift' && currentTarget) { + const sel = document.getSelection(); + + if (!sel.isCollapsed) { + selection.clear(); + upsertCurrentTarget(); + selection.userSelect(currentTarget.annotation, cloneKeyboardEvent(evt)); + } + } + } + + const onSelectAll = (evt: KeyboardEvent) => { + + const onSelected = () => setTimeout(() => { + if (currentTarget?.selector.length > 0) { + selection.clear(); + + store.addAnnotation({ + id: currentTarget.annotation, + bodies: [], + target: currentTarget + }); + + selection.userSelect(currentTarget.annotation, cloneKeyboardEvent(evt)); + } + + document.removeEventListener('selectionchange', onSelected); + + // Sigh... this needs a delay to work. But doesn't seem reliable. + }, 100); + + // Listen to the change event that follows + document.addEventListener('selectionchange', onSelected); + + // Start selection! + onSelectStart(evt); + } + + hotkeys(SELECTION_KEYS.join(','), { element: container, keydown: true, keyup: false }, evt => { if (!evt.repeat) lastDownEvent = cloneKeyboardEvent(evt); }); + hotkeys(SELECT_ALL, { keydown: true, keyup: false}, evt => { + lastDownEvent = cloneKeyboardEvent(evt); + onSelectAll(evt); + }); + /** * Free caret movement through the text resets the annotation selection. * @@ -253,8 +328,10 @@ export const SelectionHandler = ( container.addEventListener('pointerdown', onPointerDown); document.addEventListener('pointerup', onPointerUp); + document.addEventListener('contextmenu', onContextMenu); if (annotatingEnabled) { + container.addEventListener('keyup', onKeyup); container.addEventListener('selectstart', onSelectStart); document.addEventListener('selectionchange', onSelectionChange); } @@ -262,7 +339,9 @@ export const SelectionHandler = ( const destroy = () => { container.removeEventListener('pointerdown', onPointerDown); document.removeEventListener('pointerup', onPointerUp); + document.removeEventListener('contextmenu', onContextMenu); + container.removeEventListener('keyup', onKeyup); container.removeEventListener('selectstart', onSelectStart); document.removeEventListener('selectionchange', onSelectionChange); diff --git a/packages/text-annotator/src/utils/device.ts b/packages/text-annotator/src/utils/device.ts new file mode 100644 index 00000000..a935d82a --- /dev/null +++ b/packages/text-annotator/src/utils/device.ts @@ -0,0 +1,2 @@ +// @ts-ignore +export const isMac = /mac/i.test(navigator.userAgentData ? navigator.userAgentData.platform : navigator.platform); diff --git a/packages/text-annotator/src/utils/index.ts b/packages/text-annotator/src/utils/index.ts index f1284390..fe84a63b 100644 --- a/packages/text-annotator/src/utils/index.ts +++ b/packages/text-annotator/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './cancelSingleClickEvents'; +export * from './device'; export * from './programmaticallyFocusable'; export * from './debounce'; export * from './getAnnotatableFragment';