diff --git a/package-lock.json b/package-lock.json index 7110c478..a6f35170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3028,6 +3028,15 @@ "he": "bin/he" } }, + "node_modules/hotkeys-js": { + "version": "3.13.7", + "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.13.7.tgz", + "integrity": "sha512-ygFIdTqqwG4fFP7kkiYlvayZppeIQX2aPpirsngkv1xM1lP0piDY5QEh68nQnIKvz64hfocxhBaD/uK3sSK1yQ==", + "license": "MIT", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -3432,9 +3441,9 @@ } }, "node_modules/openseadragon": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/openseadragon/-/openseadragon-5.0.0.tgz", - "integrity": "sha512-S9aabSjmJg7Jfow1UItR5aXiKQLtkDWyRR5fxLeqT4vSYMvfscXUDfVS9snUN3JuIrHaSPJAlR4H2DYSn5DWRg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/openseadragon/-/openseadragon-4.1.1.tgz", + "integrity": "sha512-owU9gsasAcobLN+LM8lN58Xc2VDSDotY9mkrwS/NB6g9KX/PcusV4RZvhHng2RF/Q0pMziwldf62glwXoGnuzg==", "license": "BSD-3-Clause", "peer": true, "funding": { @@ -4574,6 +4583,7 @@ "@annotorious/core": "^3.0.8", "colord": "^2.9.3", "dequal": "^2.0.3", + "hotkeys-js": "^3.13.7", "rbush": "^4.0.1", "uuid": "^10.0.0" }, diff --git a/packages/text-annotator-react/package.json b/packages/text-annotator-react/package.json index 78a2307c..99307226 100644 --- a/packages/text-annotator-react/package.json +++ b/packages/text-annotator-react/package.json @@ -51,4 +51,4 @@ "@recogito/text-annotator-tei": "3.0.0-rc.46", "CETEIcean": "^1.9.3" } -} \ No newline at end of file +} diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx deleted file mode 100644 index 96b7999d..00000000 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { ReactNode, useCallback, useEffect, useState, PointerEvent } from 'react'; -import { useAnnotator, useSelection } from '@annotorious/react'; -import { type TextAnnotation, type TextAnnotator } from '@recogito/text-annotator'; -import { - autoUpdate, - inline, - offset, - flip, - shift, - useDismiss, - useFloating, - useInteractions, - useRole -} from '@floating-ui/react'; - -interface TextAnnotationPopupProps { - - popup(props: TextAnnotationPopupContentProps): ReactNode; - -} - -export interface TextAnnotationPopupContentProps { - - annotation: TextAnnotation; - - editable?: boolean; - - event?: PointerEvent; - -} - -export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { - - const r = useAnnotator(); - - const { selected, event } = useSelection(); - - const annotation = selected[0]?.annotation; - - const [isOpen, setOpen] = useState(selected?.length > 0); - - const { refs, floatingStyles, update, context } = useFloating({ - placement: 'top', - open: isOpen, - onOpenChange: (open, _event, reason) => { - setOpen(open); - if (!open && reason === 'escape-key') { - r?.cancelSelected(); - } - }, - middleware: [ - offset(10), - inline(), - flip(), - shift({ mainAxis: false, crossAxis: true, padding: 10 }) - ], - whileElementsMounted: autoUpdate - }); - - const dismiss = useDismiss(context); - - const role = useRole(context, { role: 'tooltip' }); - - const { getFloatingProps } = useInteractions([dismiss, role]); - - const selectedKey = selected.map(a => a.annotation.id).join('-'); - - useEffect(() => { - // Ignore all selection changes except those accompanied by a pointer event. - if (event) - setOpen(selected.length > 0 && event.type === 'pointerup'); - }, [event?.type, selectedKey]); - - useEffect(() => { - if (!isOpen || !annotation) return; - - const { - target: { - selector: [{ range }] - } - } = annotation; - - refs.setPositionReference({ - getBoundingClientRect: range.getBoundingClientRect.bind(range), - getClientRects: range.getClientRects.bind(range) - }); - }, [isOpen, annotation, refs]); - - // Prevent text-annotator from handling the irrelevant events triggered from the popup - const getStopEventsPropagationProps = useCallback( - () => ({ onPointerUp: (event: PointerEvent) => event.stopPropagation() }), - [] - ); - - useEffect(() => { - const config: MutationObserverInit = { attributes: true, childList: true, subtree: true }; - - const mutationObserver = new MutationObserver(() => update()); - mutationObserver.observe(document.body, config); - - window.document.addEventListener('scroll', update, true); - - return () => { - mutationObserver.disconnect(); - window.document.removeEventListener('scroll', update, true); - } - }, [update]); - - return isOpen && selected.length > 0 ? ( -
- {props.popup({ - annotation: selected[0].annotation, - editable: selected[0].editable, - event - })} -
- ) : null; - -} diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.css b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.css new file mode 100644 index 00000000..21ad1759 --- /dev/null +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.css @@ -0,0 +1,30 @@ +/* + * Close message should be visible only to the keyboard + * or the screen reader users as the popup behavior hint + * Inspired by https://gist.github.com/ffoodd/000b59f431e3e64e4ce1a24d5bb36034 + */ +.popup-close-message { + border: 0 !important; + clip: rect(1px, 1px, 1px, 1px); + -webkit-clip-path: inset(50%); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + white-space: nowrap; +} + +.popup-close-message:focus, +.popup-close-message:active { + clip: auto; + -webkit-clip-path: none; + clip-path: none; + height: auto; + margin: auto; + overflow: visible; + width: auto; + white-space: normal; +} diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx new file mode 100644 index 00000000..d001400e --- /dev/null +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx @@ -0,0 +1,164 @@ +import React, { PointerEvent, ReactNode, useCallback, useEffect, useState } from 'react'; +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + inline, + offset, + shift, + useDismiss, + useFloating, + useInteractions, + useRole +} from '@floating-ui/react'; + +import { useAnnotator, useSelection } from '@annotorious/react'; +import type { TextAnnotation, TextAnnotator } from '@recogito/text-annotator'; + +import './TextAnnotatorPopup.css'; + +interface TextAnnotationPopupProps { + + popup(props: TextAnnotationPopupContentProps): ReactNode; + +} + +export interface TextAnnotationPopupContentProps { + + annotation: TextAnnotation; + + editable?: boolean; + + event?: PointerEvent; + +} + +export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { + + const r = useAnnotator(); + + const { selected, event } = useSelection(); + const annotation = selected[0]?.annotation; + + const [isOpen, setOpen] = useState(selected?.length > 0); + + const handleClose = () => { + r?.cancelSelected(); + }; + + const { refs, floatingStyles, update, context } = useFloating({ + placement: 'top', + open: isOpen, + onOpenChange: (open, _event, reason) => { + setOpen(open); + + if (!open) { + if (reason === 'escape-key' || reason === 'focus-out') { + r?.cancelSelected(); + } + } + }, + middleware: [ + offset(10), + inline(), + flip(), + shift({ mainAxis: false, crossAxis: true, padding: 10 }) + ], + whileElementsMounted: autoUpdate + }); + + 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]); + + useEffect(() => { + // Close the popup if the selection is cleared + if (selected.length === 0 && isOpen) { + setOpen(false); + } + }, [isOpen, selectedKey]); + + useEffect(() => { + if (isOpen && annotation) { + const { + target: { + selector: [{ range }] + } + } = annotation; + + refs.setPositionReference({ + getBoundingClientRect: range.getBoundingClientRect.bind(range), + getClientRects: range.getClientRects.bind(range) + }); + } 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) => event.stopPropagation() }), + [] + ); + + useEffect(() => { + const config: MutationObserverInit = { attributes: true, childList: true, subtree: true }; + + const mutationObserver = new MutationObserver(() => update()); + mutationObserver.observe(document.body, config); + + window.document.addEventListener('scroll', update, true); + + return () => { + mutationObserver.disconnect(); + window.document.removeEventListener('scroll', update, true); + }; + }, [update]); + + return isOpen && selected.length > 0 ? ( + + +
+ {props.popup({ + annotation: selected[0].annotation, + editable: selected[0].editable, + event + })} + + {/* It lets keyboard/sr users to know that the dialog closes when they focus out of it */} + +
+
+
+ ) : null; + +}; diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/index.ts b/packages/text-annotator-react/src/TextAnnotatorPopup/index.ts new file mode 100644 index 00000000..259f9b65 --- /dev/null +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/index.ts @@ -0,0 +1 @@ +export * from './TextAnnotatorPopup'; diff --git a/packages/text-annotator-react/src/index.ts b/packages/text-annotator-react/src/index.ts index e78f957b..645d635f 100644 --- a/packages/text-annotator-react/src/index.ts +++ b/packages/text-annotator-react/src/index.ts @@ -30,7 +30,7 @@ export type { export { createBody, Origin, - UserSelectAction, + UserSelectAction } from '@annotorious/core'; // Essential re-exports from @annotorious/react diff --git a/packages/text-annotator-react/test/App.tsx b/packages/text-annotator-react/test/App.tsx index 4679b7ce..d638e588 100644 --- a/packages/text-annotator-react/test/App.tsx +++ b/packages/text-annotator-react/test/App.tsx @@ -1,75 +1,68 @@ -import React, { useCallback, useEffect, useRef } from 'react'; -import { AnnotationBody, Annotorious, useAnnotationStore, useAnnotator } from '@annotorious/react'; +import React, { FC, useCallback, useEffect } from 'react'; +import { AnnotationBody, Annotorious, useAnnotationStore, useAnnotator, useSelection } from '@annotorious/react'; import { TextAnnotator, TextAnnotatorPopup, type TextAnnotationPopupContentProps } from '../src'; import { W3CTextFormat, type TextAnnotation, type TextAnnotator as RecogitoTextAnnotator } from '@recogito/text-annotator'; -const TestPopup = (props: TextAnnotationPopupContentProps) => { +const TestPopup: FC = (props) => { - const store = useAnnotationStore(); - const anno = useAnnotator(); + const { annotation } = props; - const inputRef = useRef(null); + const store = useAnnotationStore(); + const r = useAnnotator(); const body: AnnotationBody = { id: `${Math.random()}`, - annotation: props.annotation.id, + annotation: annotation.id, purpose: 'commenting', value: 'A Dummy Comment' }; const onClick = () => { store!.addBody(body); - anno.cancelSelected(); + r.cancelSelected(); }; - useEffect(() => { - const { current: inputEl } = inputRef; - if (!inputEl) return; - - setTimeout(() => inputEl.focus({ preventScroll: true })); - }, []); - return (
- +
); -}; +} -const MockStorage = () => { +const MockStorage: FC = () => { - const anno = useAnnotator(); + const r = useAnnotator(); useEffect(() => { - if (!anno) return; + if (!r) return; const handleCreateAnnotation = (annotation: TextAnnotation) => console.log('create', annotation); - anno.on('createAnnotation', handleCreateAnnotation); + r.on('createAnnotation', handleCreateAnnotation); const handleDeleteAnnotation = (annotation: TextAnnotation) => console.log('delete', annotation); - anno.on('deleteAnnotation', handleDeleteAnnotation); + r.on('deleteAnnotation', handleDeleteAnnotation); const handleSelectionChanged = (annotations: TextAnnotation[]) => console.log('selection changed', annotations); - anno.on('selectionChanged', handleSelectionChanged); + r.on('selectionChanged', handleSelectionChanged); const handleUpdateAnnotation = (annotation: TextAnnotation, previous: TextAnnotation) => console.log('update', annotation, previous); - anno.on('updateAnnotation', handleUpdateAnnotation); + r.on('updateAnnotation', handleUpdateAnnotation); return () => { - anno.off('createAnnotation', handleCreateAnnotation); - anno.off('deleteAnnotation', handleDeleteAnnotation); - anno.off('selectionChanged', handleSelectionChanged); - anno.off('updateAnnotation', handleUpdateAnnotation); + r.off('createAnnotation', handleCreateAnnotation); + r.off('deleteAnnotation', handleDeleteAnnotation); + r.off('selectionChanged', handleSelectionChanged); + r.off('updateAnnotation', handleUpdateAnnotation); }; - }, [anno]); + }, [r]); return null; }; -export const App = () => { +export const App: FC = () => { const w3cAdapter = useCallback((container: HTMLElement) => W3CTextFormat('https://www.gutenberg.org', container), []); return ( diff --git a/packages/text-annotator-react/test/index.html b/packages/text-annotator-react/test/index.html index 82e44387..cf426691 100644 --- a/packages/text-annotator-react/test/index.html +++ b/packages/text-annotator-react/test/index.html @@ -25,13 +25,10 @@ } .annotation-popup { - z-index: 1; - } - - .popup { background-color: #fff; border: 1px solid gray; padding: 20px; + z-index: 1; } #debug { diff --git a/packages/text-annotator-react/test/tei/App.tsx b/packages/text-annotator-react/test/tei/App.tsx index 57237ddb..96a2e22f 100644 --- a/packages/text-annotator-react/test/tei/App.tsx +++ b/packages/text-annotator-react/test/tei/App.tsx @@ -1,36 +1,31 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { FC, useEffect, useRef, useState } from 'react'; import { AnnotationBody, Annotorious, useAnnotationStore, useAnnotator } from '@annotorious/react'; import { TextAnnotation, TextAnnotator as VanillaTextAnnotator } from '@recogito/text-annotator'; import { TEIAnnotator, CETEIcean, TextAnnotatorPopup, type TextAnnotationPopupContentProps } from '../../src'; -const TestPopup = (props: TextAnnotationPopupContentProps) => { +const TestPopup: FC = (props) => { + + const { annotation } = props; const store = useAnnotationStore(); - const anno = useAnnotator(); + const r = useAnnotator(); const inputRef = useRef(null); const body: AnnotationBody = { id: `${Math.random()}`, - annotation: props.annotation.id, + annotation: annotation.id, purpose: 'commenting', value: 'A Dummy Comment' }; const onClick = () => { store.addBody(body); - anno.cancelSelected(); + r.cancelSelected(); }; - useEffect(() => { - const { current: inputEl } = inputRef; - if (!inputEl) return; - - setTimeout(() => inputEl.focus({ preventScroll: true })); - }, []); - return (
@@ -42,33 +37,33 @@ const TestPopup = (props: TextAnnotationPopupContentProps) => { const MockStorage = () => { - const anno = useAnnotator(); + const r = useAnnotator(); useEffect(() => { - if (anno) { - anno.on('createAnnotation', (annotation: TextAnnotation) => { + if (r) { + r.on('createAnnotation', (annotation: TextAnnotation) => { console.log('create', annotation); }); - anno.on('deleteAnnotation', (annotation: TextAnnotation) => { + r.on('deleteAnnotation', (annotation: TextAnnotation) => { console.log('delete', annotation); }); - anno.on('selectionChanged', (annotations: TextAnnotation[]) => { + r.on('selectionChanged', (annotations: TextAnnotation[]) => { console.log('selection changed', annotations); }); - anno.on('updateAnnotation', (annotation: TextAnnotation, previous: TextAnnotation) => { + r.on('updateAnnotation', (annotation: TextAnnotation, previous: TextAnnotation) => { console.log('update', annotation, previous); }); } - }, [anno]); + }, [r]); return null; }; -export const App = () => { +export const App: FC = () => { const [tei, setTEI] = useState(undefined); diff --git a/packages/text-annotator-react/test/tei/index.html b/packages/text-annotator-react/test/tei/index.html index 6676afd4..ec568fab 100644 --- a/packages/text-annotator-react/test/tei/index.html +++ b/packages/text-annotator-react/test/tei/index.html @@ -34,13 +34,10 @@ } .annotation-popup { - z-index: 1; - } - - .popup { background-color: #fff; border: 1px solid gray; padding: 20px; + z-index: 1; } diff --git a/packages/text-annotator/package.json b/packages/text-annotator/package.json index 2e1522df..019e57be 100644 --- a/packages/text-annotator/package.json +++ b/packages/text-annotator/package.json @@ -40,7 +40,8 @@ "@annotorious/core": "^3.0.8", "colord": "^2.9.3", "dequal": "^2.0.3", + "hotkeys-js": "^3.13.7", "rbush": "^4.0.1", "uuid": "^10.0.0" } -} \ No newline at end of file +} diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index b44235f9..8b1debb7 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -1,8 +1,12 @@ -import { type Filter, Origin, type User } from '@annotorious/core'; +import { type Filter, Origin, type Selection, type User } from '@annotorious/core'; import { v4 as uuidv4 } from 'uuid'; +import hotkeys from 'hotkeys-js'; + import type { TextAnnotatorState } from './state'; import type { TextAnnotation, TextAnnotationTarget } from './model'; import { + clonePointerEvent, + cloneKeyboardEvent, debounce, splitAnnotatableRanges, rangeToSelector, @@ -11,6 +15,8 @@ import { NOT_ANNOTATABLE_SELECTOR } from './utils'; +const CLICK_TIMEOUT = 300; + export const SelectionHandler = ( container: HTMLElement, state: TextAnnotatorState, @@ -30,31 +36,31 @@ export const SelectionHandler = ( let currentTarget: TextAnnotationTarget | undefined; - let isLeftClick = false; + let isLeftClick: boolean | undefined; - let lastPointerDown: PointerEvent | undefined; + let lastDownEvent: Selection['event'] | undefined; - const onSelectStart = (evt: PointerEvent) => { - if (!isLeftClick) return; + const onSelectStart = (evt: Event) => { + if (isLeftClick === false) + return; - // Make sure we don't listen to selection changes that were - // not started on the container, or which are not supposed to - // be annotatable (like a component popup). - // Note that Chrome/iOS will sometimes return the root doc as target! + /** + * Make sure we don't listen to selection changes that were + * not started on the container, or which are not supposed to + * be annotatable (like a component popup). + * Note that Chrome/iOS will sometimes return the root doc as target! + */ const annotatable = !(evt.target as Node).parentElement?.closest(NOT_ANNOTATABLE_SELECTOR); - if (annotatable) { - currentTarget = { - annotation: uuidv4(), - selector: [], - creator: currentUser, - created: new Date() - }; - } else { - currentTarget = undefined; - } - } - const onSelectionChange = debounce((evt: PointerEvent) => { + currentTarget = annotatable ? { + annotation: uuidv4(), + selector: [], + creator: currentUser, + created: new Date() + } : undefined; + }; + + const onSelectionChange = debounce((evt: Event) => { const sel = document.getSelection(); // This is to handle cases where the selection is "hijacked" by another element @@ -66,12 +72,47 @@ export const SelectionHandler = ( return; } - // Chrome/iOS does not reliably fire the 'selectstart' event! - const timeDifference = evt.timeStamp - (lastPointerDown?.timeStamp || evt.timeStamp); - if (timeDifference < 1000 && !currentTarget) - onSelectStart(lastPointerDown); + const timeDifference = evt.timeStamp - (lastDownEvent?.timeStamp || evt.timeStamp); - if (sel.isCollapsed || !isLeftClick || !currentTarget) return; + /** + * The selection start needs to be emulated only for the pointer events! + * The keyboard ones are consistently fired on desktops + * and the `timeDifference` will always be <10ms. between the `keydown` and `selectionchange` + */ + if (lastDownEvent?.type === 'pointerdown') { + if (timeDifference < 1000 && !currentTarget) { + + // 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 + */ + onSelectStart(lastDownEvent || evt); + + } + } + + // The selection isn't active -> bail out from selection change processing + if (!currentTarget) return; + + if (sel.isCollapsed) { + /** + * The selection range got collapsed during the selecting process. + * The previously created annotation isn't relevant anymore and can be discarded + * + * @see https://github.com/recogito/text-annotator-js/issues/139 + */ + if (store.getAnnotation(currentTarget.annotation)) { + selection.clear(); + store.deleteAnnotation(currentTarget.annotation); + } + + return; + } const selectionRange = sel.getRangeAt(0); @@ -106,29 +147,33 @@ export const SelectionHandler = ( target: currentTarget }); - // ...then make the new annotation the current selection. (Reminder: - // select events don't have offsetX/offsetY - reuse last up/down) - selection.userSelect(currentTarget.annotation, lastPointerDown); + // ...then make the new annotation the current selection + selection.userSelect(currentTarget.annotation, lastDownEvent); } }); - // Select events don't carry information about the mouse button - // Therefore, to prevent right-click selection, we need to listen - // to the initial pointerdown event and remember the button + /** + * Select events don't carry information about the mouse button + * Therefore, to prevent right-click selection, we need to listen + * to the initial pointerdown event and remember the button + */ const onPointerDown = (evt: PointerEvent) => { - // Note that the event itself can be ephemeral! - const { target, timeStamp, offsetX, offsetY, type } = evt; - lastPointerDown = { ...evt, target, timeStamp, offsetX, offsetY, type }; + const annotatable = !(evt.target as Node).parentElement?.closest(NOT_ANNOTATABLE_SELECTOR); + if (!annotatable) return; - isLeftClick = evt.button === 0; - } + /** + * Cloning the event to prevent it from accidentally being `undefined` + * @see https://github.com/recogito/text-annotator-js/commit/65d13f3108c429311cf8c2523f6babbbc946013d#r144033948 + */ + lastDownEvent = clonePointerEvent(evt); + isLeftClick = lastDownEvent.button === 0; + }; const onPointerUp = (evt: PointerEvent) => { const annotatable = !(evt.target as Node).parentElement?.closest(NOT_ANNOTATABLE_SELECTOR); - if (!annotatable || !isLeftClick) - return; + if (!annotatable || !isLeftClick) return; - // Logic for selecting an existing annotation by clicking it + // Logic for selecting an existing annotation const clickSelect = () => { const { x, y } = container.getBoundingClientRect(); @@ -145,11 +190,12 @@ export const SelectionHandler = ( } else if (!selection.isEmpty()) { selection.clear(); } - } + }; - const timeDifference = evt.timeStamp - lastPointerDown.timeStamp; + const sel = document.getSelection(); + const timeDifference = evt.timeStamp - lastDownEvent.timeStamp; - /** + /** * We must check the `isCollapsed` within the 0-timeout * to handle the annotation dismissal after a click properly. * @@ -162,36 +208,70 @@ export const SelectionHandler = ( const sel = document.getSelection() // Just a click, not a selection - if (sel?.isCollapsed && timeDifference < 300) { + if (sel?.isCollapsed && timeDifference < CLICK_TIMEOUT) { currentTarget = undefined; clickSelect(); - } else if (currentTarget) { + } else if (currentTarget && store.getAnnotation(currentTarget.annotation)) { selection.userSelect(currentTarget.annotation, evt); } }); } - container.addEventListener('pointerdown', onPointerDown); + document.addEventListener('pointerdown', onPointerDown); document.addEventListener('pointerup', onPointerUp); - if (annotatingEnabled) { - container.addEventListener('selectstart', onSelectStart); - document.addEventListener('selectionchange', onSelectionChange); - } + container.addEventListener('selectstart', onSelectStart); + document.addEventListener('selectionchange', onSelectionChange); + + const arrowKeys = ['up', 'down', 'left', 'right']; + const selectionKeys = [ + ...arrowKeys.map(key => `shift+${key}`), + 'ctrl+a', + '⌘+a' + ]; + + hotkeys(selectionKeys.join(','), { element: container, keydown: true, keyup: false }, (evt) => { + if (!evt.repeat) { + lastDownEvent = cloneKeyboardEvent(evt); + } + }); + + /** + * Free caret movement through the text resets the annotation selection. + * + * It should be handled only on: + * - the annotatable `container`, where the text is. + * - the `body`, where the focus goes when user closes the popup, + * or clicks the button that gets unmounted, e.g. "Close" + */ + const handleArrowKeyPress = (evt: KeyboardEvent) => { + if ( + evt.repeat || + evt.target !== container && evt.target !== document.body + ) { + return; + } + + currentTarget = undefined; + selection.clear(); + }; + hotkeys(arrowKeys.join(','), { keydown: true, keyup: false }, handleArrowKeyPress); const destroy = () => { - container.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('pointerdown', onPointerDown); document.removeEventListener('pointerup', onPointerUp); container.removeEventListener('selectstart', onSelectStart); document.removeEventListener('selectionchange', onSelectionChange); - } + hotkeys.unbind(); + }; return { destroy, setFilter, setUser - } + }; + +}; -} diff --git a/packages/text-annotator/src/TextAnnotator.css b/packages/text-annotator/src/TextAnnotator.css index 016210c7..f077a804 100644 --- a/packages/text-annotator/src/TextAnnotator.css +++ b/packages/text-annotator/src/TextAnnotator.css @@ -9,6 +9,10 @@ html, body { -webkit-tap-highlight-color: transparent; } +.r6o-annotatable.no-focus-outline { + outline: none +} + .hovered * { cursor: pointer; } @@ -17,6 +21,6 @@ html, body { background: rgba(0, 128, 255, 0.18); } -::-moz-selection, ::-moz-selection{ +::-moz-selection, ::-moz-selection { background: rgba(0, 128, 255, 0.18); } diff --git a/packages/text-annotator/src/TextAnnotator.ts b/packages/text-annotator/src/TextAnnotator.ts index 955c0413..a6995347 100644 --- a/packages/text-annotator/src/TextAnnotator.ts +++ b/packages/text-annotator/src/TextAnnotator.ts @@ -6,7 +6,7 @@ import { createPresencePainter } from './presence'; import { scrollIntoView } from './api'; import { type TextAnnotationStore, type TextAnnotatorState, createTextAnnotatorState } from './state'; import type { TextAnnotation } from './model'; -import { cancelSingleClickEvents } from './utils'; +import { cancelSingleClickEvents, programmaticallyFocusable } from './utils'; import { fillDefaults, type RendererType, type TextAnnotatorOptions } from './TextAnnotatorOptions'; import { SelectionHandler } from './SelectionHandler'; @@ -34,6 +34,9 @@ export const createTextAnnotator = ( // Prevent mobile browsers from triggering word selection on single click. cancelSingleClickEvents(container); + // Make sure that the container is focusable and can receive both pointer and keyboard events + programmaticallyFocusable(container); + const opts = fillDefaults(options, { annotatingEnabled: true, user: createAnonymousGuest() diff --git a/packages/text-annotator/src/utils/cancelSingleClickEvents.ts b/packages/text-annotator/src/utils/cancelSingleClickEvents.ts index 9eeb0d21..7d671988 100644 --- a/packages/text-annotator/src/utils/cancelSingleClickEvents.ts +++ b/packages/text-annotator/src/utils/cancelSingleClickEvents.ts @@ -1,11 +1,11 @@ import { NOT_ANNOTATABLE_SELECTOR } from './splitAnnotatableRanges'; -/** +/** * Calls .preventDefault() on click events in annotable areas, in order * to prevent problematic default browser behavior. (Specifically: keep * Chrome Android from triggering word selection on single click.) */ -export const cancelSingleClickEvents = (container: HTMLElement) => { +export const cancelSingleClickEvents = (container: HTMLElement) => container.addEventListener('click', event => { const targetElement = event.target as HTMLElement; @@ -18,4 +18,3 @@ export const cancelSingleClickEvents = (container: HTMLElement) => { if (shouldPrevent) event.preventDefault(); }); -} \ No newline at end of file diff --git a/packages/text-annotator/src/utils/cloneEvents.ts b/packages/text-annotator/src/utils/cloneEvents.ts new file mode 100644 index 00000000..768ebd3b --- /dev/null +++ b/packages/text-annotator/src/utils/cloneEvents.ts @@ -0,0 +1,55 @@ +/** + * Events need to be manually mapped into new objects: + * 1. It preserves the `target` and `currentTarget` properties. + * Otherwise, they will be `null` when the event is read beyond the handler. + * 2. Spread operator can copy only own enumerable properties, not inherited ones. + * Therefore, we need to manually copy the props we're interested in. + * @see https://developer.mozilla.org/en-US/docs/Web/API/Event/currentTarget + * @see https://github.com/recogito/text-annotator-js/commit/65d13f3108c429311cf8c2523f6babbbc946013d#r144041390 + */ + +export const clonePointerEvent = (event: PointerEvent): PointerEvent => ({ + ...event, + type: event.type, + x: event.x, + y: event.y, + clientX: event.clientX, + clientY: event.clientY, + offsetX: event.offsetX, + offsetY: event.offsetY, + screenX: event.screenX, + screenY: event.screenY, + isPrimary: event.isPrimary, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + button: event.button, + buttons: event.buttons, + currentTarget: event.currentTarget, + target: event.target, + defaultPrevented: event.defaultPrevented, + detail: event.detail, + eventPhase: event.eventPhase, + pointerId: event.pointerId, + pointerType: event.pointerType, + timeStamp: event.timeStamp +}) + +export const cloneKeyboardEvent = (event: KeyboardEvent): KeyboardEvent => ({ + ...event, + type: event.type, + key: event.key, + code: event.code, + location: event.location, + repeat: event.repeat, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + currentTarget: event.currentTarget, + target: event.target, + defaultPrevented: event.defaultPrevented, + detail: event.detail, + timeStamp: event.timeStamp +}) diff --git a/packages/text-annotator/src/utils/index.ts b/packages/text-annotator/src/utils/index.ts index 9a638ac5..f1284390 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 './programmaticallyFocusable'; export * from './debounce'; export * from './getAnnotatableFragment'; export * from './getQuoteContext'; @@ -11,4 +12,5 @@ export * from './reviveSelector'; export * from './reviveTarget'; export * from './splitAnnotatableRanges'; export * from './trimRangeToContainer'; +export * from './cloneEvents'; diff --git a/packages/text-annotator/src/utils/programmaticallyFocusable.ts b/packages/text-annotator/src/utils/programmaticallyFocusable.ts new file mode 100644 index 00000000..d11370d6 --- /dev/null +++ b/packages/text-annotator/src/utils/programmaticallyFocusable.ts @@ -0,0 +1,12 @@ +/** + * Makes an element programmatically focusable by adding a `tabindex="-1"` attribute. + * Or does nothing if the element is already focusable 🤷🏻 + * It's required to process keyboard events on an element that is not natively focusable. + */ +export const programmaticallyFocusable = (container: HTMLElement) => { + if (!container.hasAttribute('tabindex') && container.tabIndex < 0) { + container.setAttribute('tabindex', '-1'); + } + + container.classList.add('no-focus-outline'); +};