diff --git a/package-lock.json b/package-lock.json index 1a1f6893..cf4dc3e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3602,6 +3602,12 @@ "pathe": "^2.0.3" } }, + "node_modules/poll": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/poll/-/poll-3.2.2.tgz", + "integrity": "sha512-qckJRcLqqsX72Uu/Sa/GbzWUXB/zZcyMNccwdGFQnYoewnXUdWyMWkHUmaiBAvm950ujJJ15OiFFy+gtBm1K+A==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", @@ -4701,6 +4707,7 @@ "colord": "^2.9.3", "dequal": "^2.0.3", "hotkeys-js": "^3.13.10", + "poll": "^3.2.2", "rbush": "^4.0.1", "uuid": "^11.1.0" }, diff --git a/packages/text-annotator/package.json b/packages/text-annotator/package.json index a143aa4e..df4d10ba 100644 --- a/packages/text-annotator/package.json +++ b/packages/text-annotator/package.json @@ -39,7 +39,8 @@ "colord": "^2.9.3", "dequal": "^2.0.3", "hotkeys-js": "^3.13.10", + "poll": "^3.2.2", "rbush": "^4.0.1", "uuid": "^11.1.0" } -} \ No newline at end of file +} diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 3b36118e..8a6a2d32 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -2,6 +2,8 @@ import { Origin } from '@annotorious/core'; import type { Filter, Selection, User } from '@annotorious/core'; import { v4 as uuidv4 } from 'uuid'; import hotkeys from 'hotkeys-js'; +import { poll } from 'poll'; + import type { TextAnnotatorState } from './state'; import type { TextAnnotation, TextAnnotationTarget } from './model'; import type { TextAnnotatorOptions } from './TextAnnotatorOptions'; @@ -52,7 +54,7 @@ export const SelectionHandler = ( let lastDownEvent: Selection['event'] | undefined; - const onSelectStart = (evt: Event) => { + const onSelectStart = (evt: Event) => { if (isLeftClick === false) return; @@ -184,7 +186,7 @@ export const SelectionHandler = ( isLeftClick = lastDownEvent.button === 0; }; - const onPointerUp = (evt: PointerEvent) => { + const onPointerUp = async (evt: PointerEvent) => { if (isNotAnnotatable(evt.target as Node) || !isLeftClick) return; // Logic for selecting an existing annotation @@ -213,29 +215,48 @@ export const SelectionHandler = ( } }; + const timeDifference = evt.timeStamp - lastDownEvent.timeStamp; + if (timeDifference < CLICK_TIMEOUT) { + await pollSelectionCollapsed(); - /** - * We must check the `isCollapsed` within the 0-timeout - * to handle the annotation dismissal after a click properly. - * - * Otherwise, the `isCollapsed` will return an obsolete `false` value, - * click won't be processed, and the annotation will get falsely re-selected. - * - * @see https://github.com/recogito/text-annotator-js/issues/136 - */ - setTimeout(() => { const sel = document.getSelection(); - - // Just a click, not a selection - if (sel?.isCollapsed && timeDifference < CLICK_TIMEOUT) { + if (sel?.isCollapsed) { currentTarget = undefined; clickSelect(); - } else if (currentTarget && currentTarget.selector.length > 0) { - upsertCurrentTarget(); - selection.userSelect(currentTarget.annotation, clonePointerEvent(evt)); + return; } - }); + } + + if (currentTarget && currentTarget.selector.length > 0) { + + upsertCurrentTarget(); + selection.userSelect(currentTarget.annotation, clonePointerEvent(evt)); + } + } + + /** + * We must check the `isCollapsed` after an unspecified timeout + * to handle the annotation dismissal after a click properly. + * + * Otherwise, the `isCollapsed` will return an obsolete `false` value, + * click won't be processed, and the annotation will get falsely re-selected. + * + * @see https://github.com/recogito/text-annotator-js/issues/136#issue-2465915707 + * @see https://github.com/recogito/text-annotator-js/issues/136#issuecomment-2413773804 + */ + const pollSelectionCollapsed = async () => { + const sel = document.getSelection(); + + let stopPolling = false; + let isCollapsed = sel?.isCollapsed; + const shouldStopPolling = () => isCollapsed || stopPolling; + + const pollingDelayMs = 1; + const stopPollingInMs = 50; + setTimeout(() => stopPolling = true, stopPollingInMs); + + return poll(() => isCollapsed = sel?.isCollapsed, pollingDelayMs, shouldStopPolling); } const onContextMenu = (evt: PointerEvent) => { @@ -245,18 +266,16 @@ export const SelectionHandler = ( /** * When selecting the initial word, Chrome Android - * fires the `contextmenu` before the `selectionchange` + * fires the`contextmenu`before the `selectionchange` */ if (!currentTarget || currentTarget.selector.length === 0) { onSelectionChange(evt); } - - /** +/** * The selection couldn't be initiated, * as it might span over a not-annotatable element. */ if (!currentTarget) return; - upsertCurrentTarget(); selection.userSelect(currentTarget.annotation, clonePointerEvent(evt));