diff --git a/package-lock.json b/package-lock.json index a432e9ac..82003495 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3419,6 +3419,12 @@ "pathe": "^1.1.2" } }, + "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.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", @@ -5483,6 +5489,7 @@ "colord": "^2.9.3", "dequal": "^2.0.3", "hotkeys-js": "^3.13.9", + "poll": "^3.2.2", "rbush": "^4.0.1", "uuid": "^11.0.3" }, diff --git a/packages/text-annotator/package.json b/packages/text-annotator/package.json index 4e684700..5d7c3a76 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.9", + "poll": "^3.2.2", "rbush": "^4.0.1", "uuid": "^11.0.3" } -} \ No newline at end of file +} diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index d6451432..e513d676 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'; @@ -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) => { @@ -243,12 +264,12 @@ export const SelectionHandler = ( if (sel?.isCollapsed) return; - // When selecting the initial word, Chrome Android fires `contextmenu` + // 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));