From 21677a92105d471c472c2bf1b08f492acb21668d Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 9 Jul 2024 18:54:55 +0300 Subject: [PATCH 01/58] Adapted user select action usage --- .../text-annotator/src/SelectionHandler.ts | 18 +++++++++--------- packages/text-annotator/src/TextAnnotator.ts | 2 +- .../text-annotator/src/TextAnnotatorOptions.ts | 4 ++-- packages/text-annotator/src/index.ts | 2 +- .../src/state/TextAnnotatorState.ts | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index c75b2b60..c4590638 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -1,4 +1,4 @@ -import { Filter, Origin, type User } from '@annotorious/core'; +import { Filter, Origin, type Selection, type User } from '@annotorious/core'; import { v4 as uuidv4 } from 'uuid'; import type { TextAnnotatorState } from './state'; import type { TextAnnotationTarget } from './model'; @@ -104,9 +104,8 @@ 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.clickSelect(currentTarget.annotation, lastPointerDown); + // ...then make the new annotation the current selection + selection.userSelect(currentTarget.annotation, lastDownEvent); } }) @@ -131,8 +130,8 @@ export const SelectionHandler = ( if (!annotatable || !isLeftClick) return; - // Logic for selecting an existing annotation by clicking it - const clickSelect = () => { + // Logic for selecting an existing annotation + const userSelect = () => { const { x, y } = container.getBoundingClientRect(); const hovered = store.getAt(evt.clientX - x, evt.clientY - y, currentFilter); @@ -140,7 +139,7 @@ export const SelectionHandler = ( const { selected } = selection; if (selected.length !== 1 || selected[0].id !== hovered.id) - selection.clickSelect(hovered.id, evt); + selection.userSelect(hovered.id, evt); } else if (!selection.isEmpty()) { selection.clear(); } @@ -151,9 +150,9 @@ export const SelectionHandler = ( // Just a click, not a selection if (document.getSelection().isCollapsed && timeDifference < 300) { currentTarget = undefined; - clickSelect(); + userSelect(); } else if (currentTarget) { - selection.clickSelect(currentTarget.annotation, evt); + selection.userSelect(currentTarget.annotation, evt); } } @@ -174,3 +173,4 @@ export const SelectionHandler = ( } } + diff --git a/packages/text-annotator/src/TextAnnotator.ts b/packages/text-annotator/src/TextAnnotator.ts index 4d58471b..d958acd5 100644 --- a/packages/text-annotator/src/TextAnnotator.ts +++ b/packages/text-annotator/src/TextAnnotator.ts @@ -37,7 +37,7 @@ export const createTextAnnotator = ( annotationEnabled: true }); - const state: TextAnnotatorState = createTextAnnotatorState(container, opts.pointerAction); + const state: TextAnnotatorState = createTextAnnotatorState(container, opts.userSelectAction); const { selection, viewport } = state; diff --git a/packages/text-annotator/src/TextAnnotatorOptions.ts b/packages/text-annotator/src/TextAnnotatorOptions.ts index 97bc67af..22887666 100644 --- a/packages/text-annotator/src/TextAnnotatorOptions.ts +++ b/packages/text-annotator/src/TextAnnotatorOptions.ts @@ -1,4 +1,4 @@ -import type { FormatAdapter, PointerSelectAction } from '@annotorious/core'; +import type { FormatAdapter, UserSelectAction } from '@annotorious/core'; import type { PresencePainterOptions } from './presence'; import type { TextAnnotation } from './model'; import type { HighlightStyleExpression } from './highlight'; @@ -13,7 +13,7 @@ export interface TextAnnotatorOptions { offsetReferenceSelector?: string; - pointerAction?: PointerSelectAction | ((annotation: TextAnnotation) => PointerSelectAction); + userSelectAction?: UserSelectAction | ((annotation: TextAnnotation) => UserSelectAction); presence?: PresencePainterOptions; diff --git a/packages/text-annotator/src/index.ts b/packages/text-annotator/src/index.ts index d7d5666f..96192eee 100644 --- a/packages/text-annotator/src/index.ts +++ b/packages/text-annotator/src/index.ts @@ -32,5 +32,5 @@ export type { export { createBody, Origin, - PointerSelectAction + UserSelectAction } from '@annotorious/core'; diff --git a/packages/text-annotator/src/state/TextAnnotatorState.ts b/packages/text-annotator/src/state/TextAnnotatorState.ts index 98065c8f..b01d48fc 100644 --- a/packages/text-annotator/src/state/TextAnnotatorState.ts +++ b/packages/text-annotator/src/state/TextAnnotatorState.ts @@ -1,4 +1,4 @@ -import type { Filter, PointerSelectAction, Store, ViewportState } from '@annotorious/core'; +import type { Filter, UserSelectAction, Store, ViewportState } from '@annotorious/core'; import { createHoverState, createSelectionState, @@ -28,14 +28,14 @@ export interface TextAnnotatorState extends AnnotatorState { export const createTextAnnotatorState = ( container: HTMLElement, - defaultPointerAction?: PointerSelectAction | ((annotation: TextAnnotation) => PointerSelectAction) + userSelectAction?: UserSelectAction | ((annotation: TextAnnotation) => UserSelectAction) ): TextAnnotatorState => { const store: Store = createStore(); const tree = createSpatialTree(store, container); - const selection = createSelectionState(store, defaultPointerAction); + const selection = createSelectionState(store, userSelectAction); const hover = createHoverState(store); From 580082bc2ce2512b8d65e7bcfd3878063915e220 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 9 Jul 2024 19:40:08 +0300 Subject: [PATCH 02/58] Made the content focusable --- packages/text-annotator/src/TextAnnotator.css | 6 +++++- packages/text-annotator/src/TextAnnotator.ts | 5 ++++- .../src/utils/cancelSingleClickEvents.ts | 5 ++--- packages/text-annotator/src/utils/index.ts | 1 + .../src/utils/programmaticallyFocusable.ts | 12 ++++++++++++ 5 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 packages/text-annotator/src/utils/programmaticallyFocusable.ts diff --git a/packages/text-annotator/src/TextAnnotator.css b/packages/text-annotator/src/TextAnnotator.css index abfab129..fd2361b4 100644 --- a/packages/text-annotator/src/TextAnnotator.css +++ b/packages/text-annotator/src/TextAnnotator.css @@ -7,6 +7,10 @@ html, body { -webkit-tap-highlight-color: transparent; } +.r6o-annotatable.no-focus-outline { + outline: none +} + .r6o-annotatable, .r6o-annotatable * { position: relative; } @@ -19,6 +23,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 d958acd5..410d56c3 100644 --- a/packages/text-annotator/src/TextAnnotator.ts +++ b/packages/text-annotator/src/TextAnnotator.ts @@ -5,7 +5,7 @@ import { createPresencePainter } from './presence'; import { scrollIntoView } from './api'; import { TextAnnotationStore, 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'; @@ -33,6 +33,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, { annotationEnabled: true }); 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/index.ts b/packages/text-annotator/src/utils/index.ts index e8b379d8..c8c77ff8 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 './getClientRectsPonyfill'; 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'); +}; From e9832d53be1aa969edf4584ae4d072d058479a5b Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 9 Jul 2024 19:41:37 +0300 Subject: [PATCH 03/58] Generalized down event recording --- .../text-annotator/src/SelectionHandler.ts | 53 ++++++++++--------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index c4590638..49a84f7b 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -31,26 +31,25 @@ export const SelectionHandler = ( let isLeftClick = false; - let lastPointerDown: PointerEvent | undefined; + let lastDownEvent: Selection['event'] | undefined; - const onSelectStart = (evt: PointerEvent) => { + const onSelectStart = (evt: Event) => { if (!isLeftClick) 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; - } + + currentTarget = annotatable ? { + annotation: uuidv4(), + selector: [], + creator: currentUser, + created: new Date() + } : undefined; } if (annotationEnabled) @@ -69,8 +68,8 @@ export const SelectionHandler = ( } // Chrome/iOS does not reliably fire the 'selectstart' event! - if (evt.timeStamp - (lastPointerDown?.timeStamp || evt.timeStamp) < 1000 && !currentTarget) - onSelectStart(lastPointerDown); + if (evt.timeStamp - (lastDownEvent?.timeStamp || evt.timeStamp) < 1000 && !currentTarget) + onSelectStart(lastDownEvent); if (sel.isCollapsed || !isLeftClick || !currentTarget) return; @@ -112,17 +111,21 @@ export const SelectionHandler = ( if (annotationEnabled) document.addEventListener('selectionchange', onSelectionChange); - // 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! + /** + * Note that the event itself can be ephemeral! + * @see https://github.com/recogito/text-annotator-js/commit/65d13f3108c429311cf8c2523f6babbbc946013d#r144033948 + */ const { target, timeStamp, offsetX, offsetY, type } = evt; - lastPointerDown = { ...evt, target, timeStamp, offsetX, offsetY, type }; + lastDownEvent = { ...evt, target, timeStamp, offsetX, offsetY, type }; isLeftClick = evt.button === 0; } - container.addEventListener('pointerdown', onPointerDown); const onPointerUp = (evt: PointerEvent) => { @@ -145,7 +148,7 @@ export const SelectionHandler = ( } } - const timeDifference = evt.timeStamp - lastPointerDown.timeStamp; + const timeDifference = evt.timeStamp - lastDownEvent.timeStamp; // Just a click, not a selection if (document.getSelection().isCollapsed && timeDifference < 300) { From 11680590a69e79cfaddb30ca3aa650c5f92a277a Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 9 Jul 2024 21:06:10 +0300 Subject: [PATCH 04/58] Added explicit pointer event cloning --- .../text-annotator/src/SelectionHandler.ts | 10 +++---- .../text-annotator/src/utils/cloneEvents.ts | 30 +++++++++++++++++++ packages/text-annotator/src/utils/index.ts | 1 + 3 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 packages/text-annotator/src/utils/cloneEvents.ts diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 49a84f7b..43a54198 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import type { TextAnnotatorState } from './state'; import type { TextAnnotationTarget } from './model'; import { + clonePointerEvent, debounce, splitAnnotatableRanges, rangeToSelector, @@ -118,13 +119,11 @@ export const SelectionHandler = ( */ const onPointerDown = (evt: PointerEvent) => { /** - * Note that the event itself can be ephemeral! + * Cloning the event to prevent it from accidentally being `undefined` * @see https://github.com/recogito/text-annotator-js/commit/65d13f3108c429311cf8c2523f6babbbc946013d#r144033948 */ - const { target, timeStamp, offsetX, offsetY, type } = evt; - lastDownEvent = { ...evt, target, timeStamp, offsetX, offsetY, type }; - - isLeftClick = evt.button === 0; + lastDownEvent = clonePointerEvent(evt); + isLeftClick = lastDownEvent.button === 0; } container.addEventListener('pointerdown', onPointerDown); @@ -158,7 +157,6 @@ export const SelectionHandler = ( selection.userSelect(currentTarget.annotation, evt); } } - document.addEventListener('pointerup', onPointerUp); const destroy = () => { diff --git a/packages/text-annotator/src/utils/cloneEvents.ts b/packages/text-annotator/src/utils/cloneEvents.ts new file mode 100644 index 00000000..dd3ea5b1 --- /dev/null +++ b/packages/text-annotator/src/utils/cloneEvents.ts @@ -0,0 +1,30 @@ +/** + * Event need to be manually mapped into a new object + * to preserve the `target` and `currentTarget` properties. + * Otherwise, they will be `null` when the event is read beyond the handler. + * @see https://developer.mozilla.org/en-US/docs/Web/API/Event/currentTarget + */ +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, + button: event.button, + buttons: event.buttons, + currentTarget: event.currentTarget, + target: event.target, + defaultPrevented: event.defaultPrevented, + pointerId: event.pointerId, + pointerType: event.pointerType, + shiftKey: event.shiftKey +}) diff --git a/packages/text-annotator/src/utils/index.ts b/packages/text-annotator/src/utils/index.ts index c8c77ff8..7dddaa27 100644 --- a/packages/text-annotator/src/utils/index.ts +++ b/packages/text-annotator/src/utils/index.ts @@ -12,4 +12,5 @@ export * from './reviveAnnotation'; export * from './reviveSelector'; export * from './reviveTarget'; export * from './splitAnnotatableRanges'; +export * from './cloneEvents'; From ffa46ebf2348afd7ef11cd23cca0120d151a26c7 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 10 Jul 2024 14:32:27 +0300 Subject: [PATCH 05/58] Added keyboard event registering --- .../text-annotator/src/SelectionHandler.ts | 24 +++++++++++--- .../text-annotator/src/utils/cloneEvents.ts | 33 ++++++++++++++++--- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 43a54198..bdb5e274 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -4,6 +4,7 @@ import type { TextAnnotatorState } from './state'; import type { TextAnnotationTarget } from './model'; import { clonePointerEvent, + cloneKeyboardEvent, debounce, splitAnnotatableRanges, rangeToSelector, @@ -76,13 +77,13 @@ export const SelectionHandler = ( const selectionRange = sel.getRangeAt(0); if (isWhitespaceOrEmpty(selectionRange)) return; - + const annotatableRanges = splitAnnotatableRanges(selectionRange.cloneRange()); const hasChanged = annotatableRanges.length !== currentTarget.selector.length || annotatableRanges.some((r, i) => r.toString() !== currentTarget.selector[i]?.quote); - + if (!hasChanged) return; currentTarget = { @@ -96,7 +97,7 @@ export const SelectionHandler = ( } else { // Proper lifecycle management: clear selection first... selection.clear(); - + // ...then add annotation to store... store.addAnnotation({ id: currentTarget.annotation, @@ -127,6 +128,11 @@ export const SelectionHandler = ( } container.addEventListener('pointerdown', onPointerDown); + const onKeyDown = (evt: KeyboardEvent) => { + lastDownEvent = cloneKeyboardEvent(evt); + } + container.addEventListener('keydown', onKeyDown); + const onPointerUp = (evt: PointerEvent) => { const annotatable = !(evt.target as Node).parentElement?.closest(NOT_ANNOTATABLE_SELECTOR); if (!annotatable || !isLeftClick) @@ -159,12 +165,22 @@ export const SelectionHandler = ( } document.addEventListener('pointerup', onPointerUp); + const onKeyUp = (evt: KeyboardEvent) => { + if (currentTarget) { + selection.userSelect(currentTarget.annotation, evt); + } + } + container.addEventListener('keyup', onKeyUp); + const destroy = () => { container.removeEventListener('selectstart', onSelectStart); document.removeEventListener('selectionchange', onSelectionChange); - + container.removeEventListener('pointerdown', onPointerDown); document.removeEventListener('pointerup', onPointerUp); + + container.removeEventListener('keydown', onKeyDown); + container.removeEventListener('keyup', onKeyUp); } return { diff --git a/packages/text-annotator/src/utils/cloneEvents.ts b/packages/text-annotator/src/utils/cloneEvents.ts index dd3ea5b1..768ebd3b 100644 --- a/packages/text-annotator/src/utils/cloneEvents.ts +++ b/packages/text-annotator/src/utils/cloneEvents.ts @@ -1,9 +1,13 @@ /** - * Event need to be manually mapped into a new object - * to preserve the `target` and `currentTarget` properties. - * Otherwise, they will be `null` when the event is read beyond the handler. + * 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, @@ -19,12 +23,33 @@ export const clonePointerEvent = (event: PointerEvent): PointerEvent => ({ 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, - shiftKey: event.shiftKey + 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 }) From 0bbe0645b1254e563e90764b7228cf73d4d2bc54 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 10 Jul 2024 19:42:57 +0300 Subject: [PATCH 06/58] Re-exported select action --- packages/text-annotator-react/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/text-annotator-react/src/index.ts b/packages/text-annotator-react/src/index.ts index f074ddd0..7a3922e9 100644 --- a/packages/text-annotator-react/src/index.ts +++ b/packages/text-annotator-react/src/index.ts @@ -29,7 +29,7 @@ export type { export { createBody, Origin, - PointerSelectAction + UserSelectAction } from '@annotorious/core'; // Essential re-exports from @annotorious/react From 268c6e3aac2e588bc126ca5b94b17bcf85121fab Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 10 Jul 2024 19:46:38 +0300 Subject: [PATCH 07/58] Fixed keyboard selection detection w/o prior pointer event --- .../text-annotator/src/SelectionHandler.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index bdb5e274..92693b17 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -31,12 +31,13 @@ export const SelectionHandler = ( let currentTarget: TextAnnotationTarget | undefined; - let isLeftClick = false; + let isLeftClick: boolean | undefined; let lastDownEvent: Selection['event'] | undefined; const onSelectStart = (evt: Event) => { - if (!isLeftClick) return; + if (isLeftClick === false) + return; /** * Make sure we don't listen to selection changes that were @@ -57,7 +58,7 @@ export const SelectionHandler = ( if (annotationEnabled) container.addEventListener('selectstart', onSelectStart); - const onSelectionChange = debounce((evt: PointerEvent) => { + const onSelectionChange = debounce((evt: Event) => { const sel = document.getSelection(); // This is to handle cases where the selection is "hijacked" by another element @@ -70,10 +71,13 @@ export const SelectionHandler = ( } // Chrome/iOS does not reliably fire the 'selectstart' event! - if (evt.timeStamp - (lastDownEvent?.timeStamp || evt.timeStamp) < 1000 && !currentTarget) - onSelectStart(lastDownEvent); + if (evt.timeStamp - (lastDownEvent?.timeStamp || evt.timeStamp) < 1000 && !currentTarget) { + onSelectStart(lastDownEvent || evt); + } + + if (sel.isCollapsed || !currentTarget || isLeftClick === false) + return; - if (sel.isCollapsed || !isLeftClick || !currentTarget) return; const selectionRange = sel.getRangeAt(0); if (isWhitespaceOrEmpty(selectionRange)) return; @@ -129,7 +133,9 @@ export const SelectionHandler = ( container.addEventListener('pointerdown', onPointerDown); const onKeyDown = (evt: KeyboardEvent) => { - lastDownEvent = cloneKeyboardEvent(evt); + if (!evt.repeat) { + lastDownEvent = cloneKeyboardEvent(evt); + } } container.addEventListener('keydown', onKeyDown); @@ -166,7 +172,7 @@ export const SelectionHandler = ( document.addEventListener('pointerup', onPointerUp); const onKeyUp = (evt: KeyboardEvent) => { - if (currentTarget) { + if (!evt.repeat && currentTarget) { selection.userSelect(currentTarget.annotation, evt); } } From 1780bf0c5979c4c90a1f5fdbc052ac0f94bda320 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Fri, 12 Jul 2024 15:56:22 +0300 Subject: [PATCH 08/58] Added keyboard selection finish handling --- package-lock.json | 17 +++++++++ packages/text-annotator-react/package.json | 2 +- packages/text-annotator/package.json | 4 +- .../text-annotator/src/SelectionHandler.ts | 37 ++++++++++++++++--- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 88a676cc..a50afd58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2874,6 +2874,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", @@ -4028,6 +4037,12 @@ "node": ">=18" } }, + "node_modules/ts-key-enum": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ts-key-enum/-/ts-key-enum-2.0.12.tgz", + "integrity": "sha512-Ety4IvKMaeG34AyXMp5r11XiVZNDRL+XWxXbVVJjLvq2vxKRttEANBE7Za1bxCAZRdH2/sZT6jFyyTWxXz28hw==", + "license": "MIT" + }, "node_modules/tsconfck": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.1.tgz", @@ -4585,7 +4600,9 @@ "@annotorious/core": "^3.0.0-rc.30", "colord": "^2.9.3", "dequal": "^2.0.3", + "hotkeys-js": "^3.13.7", "rbush": "^4.0.0", + "ts-key-enum": "^2.0.12", "uuid": "^10.0.0" }, "devDependencies": { diff --git a/packages/text-annotator-react/package.json b/packages/text-annotator-react/package.json index 5bc05693..4a6b3baf 100644 --- a/packages/text-annotator-react/package.json +++ b/packages/text-annotator-react/package.json @@ -52,4 +52,4 @@ "@recogito/text-annotator-tei": "3.0.0-rc.35", "CETEIcean": "^1.9.3" } -} \ No newline at end of file +} diff --git a/packages/text-annotator/package.json b/packages/text-annotator/package.json index 917329b2..828fa345 100644 --- a/packages/text-annotator/package.json +++ b/packages/text-annotator/package.json @@ -40,7 +40,9 @@ "@annotorious/core": "^3.0.0-rc.30", "colord": "^2.9.3", "dequal": "^2.0.3", + "hotkeys-js": "^3.13.7", "rbush": "^4.0.0", + "ts-key-enum": "^2.0.12", "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 92693b17..6e950cf8 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -1,5 +1,7 @@ import { Filter, Origin, type Selection, type User } from '@annotorious/core'; import { v4 as uuidv4 } from 'uuid'; +import hotkeys from 'hotkeys-js'; +import { Key } from 'ts-key-enum'; import type { TextAnnotatorState } from './state'; import type { TextAnnotationTarget } from './model'; import { @@ -171,12 +173,35 @@ export const SelectionHandler = ( } document.addEventListener('pointerup', onPointerUp); - const onKeyUp = (evt: KeyboardEvent) => { - if (!evt.repeat && currentTarget) { - selection.userSelect(currentTarget.annotation, evt); + /** + * Track the "Shift" key lift which signifies the end of a select operation. + * Unfortunately, we cannot track modifier key immediately, so a wildcard is used + */ + hotkeys('*', { keyup: true, keydown: false }, (evt) => { + if (hotkeys.shift && evt.key === Key.Shift) { + if (!evt.repeat && currentTarget) { + selection.userSelect(currentTarget.annotation, evt); + } } - } - container.addEventListener('keyup', onKeyUp); + }); + + /** + * Track the "select all" command on lifting the keys. + * Unfortunately, system-related shortcuts can be captured + * only on `keydown` event, so an additional flag is used. + */ + let selectAllCaptured = false; + hotkeys('ctrl+a, ⌘+a', { keyup: false, keydown: true }, () => { + selectAllCaptured = true; + }); + hotkeys('*', { keyup: true, keydown: false }, (evt) => { + if (selectAllCaptured) { + if (!evt.repeat && currentTarget) { + selection.userSelect(currentTarget.annotation, evt); + } + } + selectAllCaptured = false; + }); const destroy = () => { container.removeEventListener('selectstart', onSelectStart); @@ -186,7 +211,7 @@ export const SelectionHandler = ( document.removeEventListener('pointerup', onPointerUp); container.removeEventListener('keydown', onKeyDown); - container.removeEventListener('keyup', onKeyUp); + hotkeys.unbind(); } return { From 11aae739cf748da34b805c66212a48ffa0b936f1 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Thu, 18 Jul 2024 11:29:44 +0300 Subject: [PATCH 09/58] Allowed shifting over the selection content --- .../text-annotator/src/SelectionHandler.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 6e950cf8..51b42304 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -134,13 +134,6 @@ export const SelectionHandler = ( } container.addEventListener('pointerdown', onPointerDown); - const onKeyDown = (evt: KeyboardEvent) => { - if (!evt.repeat) { - lastDownEvent = cloneKeyboardEvent(evt); - } - } - container.addEventListener('keydown', onKeyDown); - const onPointerUp = (evt: PointerEvent) => { const annotatable = !(evt.target as Node).parentElement?.closest(NOT_ANNOTATABLE_SELECTOR); if (!annotatable || !isLeftClick) @@ -173,9 +166,19 @@ export const SelectionHandler = ( } document.addEventListener('pointerup', onPointerUp); + /** + * Track arbitrary keydown events to use them during + * the `selectionchange` annotation selection. + */ + hotkeys('*', { element: container, keyup: false, keydown: true }, (evt, handler) => { + if (!evt.repeat) { + lastDownEvent = cloneKeyboardEvent(evt); + } + }); + /** * Track the "Shift" key lift which signifies the end of a select operation. - * Unfortunately, we cannot track modifier key immediately, so a wildcard is used + * Unfortunately, we cannot track modifier key immediately, so the wildcard is used. */ hotkeys('*', { keyup: true, keydown: false }, (evt) => { if (hotkeys.shift && evt.key === Key.Shift) { @@ -210,7 +213,6 @@ export const SelectionHandler = ( container.removeEventListener('pointerdown', onPointerDown); document.removeEventListener('pointerup', onPointerUp); - container.removeEventListener('keydown', onKeyDown); hotkeys.unbind(); } From e4a4b69e4685b3b441a20e3541a37b1d7d920972 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 23 Jul 2024 19:53:58 +0300 Subject: [PATCH 10/58] Added reaction to the user event --- .../text-annotator-react/src/TextAnnotatorPopup.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index 9549344d..a114caa7 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -27,7 +27,7 @@ export interface TextAnnotatorPopupProps { export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { const r = useAnnotator(); - const { selected, pointerEvent } = useSelection(); + const { selected, event } = useSelection(); const annotation = selected[0]?.annotation; const [isOpen, setOpen] = useState(selected?.length > 0); @@ -55,11 +55,11 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { const selectedKey = selected.map(a => a.annotation.id).join('-'); useEffect(() => { - // Ignore all selection changes except those accompanied by a pointer event. - if (pointerEvent) { - setOpen(selected.length > 0 && pointerEvent.type === 'pointerup'); + // Ignore all selection changes except those accompanied by a user event. + if (event) { + setOpen(selected.length > 0 && (event.type === 'pointerup' || event.type === 'keyup')); } - }, [pointerEvent?.type, selectedKey]); + }, [event, selectedKey]); useEffect(() => { if (!isOpen || !annotation) return; From 719edaf4f719d0db5a7deb297ffc6171a35a2b1e Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Fri, 16 Aug 2024 18:11:06 +0300 Subject: [PATCH 11/58] Added annotation discarding when the selection collapses --- .../text-annotator/src/SelectionHandler.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index eedef19e..5633229c 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -72,17 +72,31 @@ export const SelectionHandler = ( if (evt.timeStamp - (lastPointerDown?.timeStamp || evt.timeStamp) < 1000 && !currentTarget) onSelectStart(lastPointerDown); - if (sel.isCollapsed || !isLeftClick || !currentTarget) return; + // The selection couldn't get started -> bail out from selection change processing + if (!currentTarget) + return; + + /** + * 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 (sel.isCollapsed && store.getAnnotation(currentTarget.annotation)) { + selection.clear(); + store.deleteAnnotation(currentTarget.annotation); + return; + } const selectionRange = sel.getRangeAt(0); if (isWhitespaceOrEmpty(selectionRange)) return; - + const annotatableRanges = splitAnnotatableRanges(selectionRange.cloneRange()); const hasChanged = annotatableRanges.length !== currentTarget.selector.length || annotatableRanges.some((r, i) => r.toString() !== currentTarget.selector[i]?.quote); - + if (!hasChanged) return; currentTarget = { @@ -96,7 +110,7 @@ export const SelectionHandler = ( } else { // Proper lifecycle management: clear selection first... selection.clear(); - + // ...then add annotation to store... store.addAnnotation({ id: currentTarget.annotation, @@ -162,7 +176,7 @@ export const SelectionHandler = ( const destroy = () => { container.removeEventListener('selectstart', onSelectStart); document.removeEventListener('selectionchange', onSelectionChange); - + container.removeEventListener('pointerdown', onPointerDown); document.removeEventListener('pointerup', onPointerUp); } From e20c04e1a22d1893dc4ed809fafa20c67ab07b2b Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Fri, 16 Aug 2024 18:27:08 +0300 Subject: [PATCH 12/58] Updated `currentTarget` check comment --- packages/text-annotator/src/SelectionHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 5633229c..7fddeb38 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -72,7 +72,7 @@ export const SelectionHandler = ( if (evt.timeStamp - (lastPointerDown?.timeStamp || evt.timeStamp) < 1000 && !currentTarget) onSelectStart(lastPointerDown); - // The selection couldn't get started -> bail out from selection change processing + // The selection isn't active -> bail out from selection change processing if (!currentTarget) return; From 0bdc419afb7981cf7e78e3e43b9c5e9128d1f374 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Fri, 16 Aug 2024 20:30:09 +0300 Subject: [PATCH 13/58] Added completion for the keyboard selection --- .../text-annotator/src/SelectionHandler.ts | 46 ++++++------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 10710950..06b759ee 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -178,44 +178,26 @@ export const SelectionHandler = ( } document.addEventListener('pointerup', onPointerUp); - /** - * Track arbitrary keydown events to use them during - * the `selectionchange` annotation selection. - */ - hotkeys('*', { element: container, keyup: false, keydown: true }, (evt, handler) => { + + 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); } }); - /** - * Track the "Shift" key lift which signifies the end of a select operation. - * Unfortunately, we cannot track modifier key immediately, so the wildcard is used. - */ - hotkeys('*', { keyup: true, keydown: false }, (evt) => { - if (hotkeys.shift && evt.key === Key.Shift) { - if (!evt.repeat && currentTarget) { - selection.userSelect(currentTarget.annotation, evt); - } - } - }); - - /** - * Track the "select all" command on lifting the keys. - * Unfortunately, system-related shortcuts can be captured - * only on `keydown` event, so an additional flag is used. - */ - let selectAllCaptured = false; - hotkeys('ctrl+a, ⌘+a', { keyup: false, keydown: true }, () => { - selectAllCaptured = true; - }); - hotkeys('*', { keyup: true, keydown: false }, (evt) => { - if (selectAllCaptured) { - if (!evt.repeat && currentTarget) { - selection.userSelect(currentTarget.annotation, evt); - } + // Free caret movement through the text resets the annotation selection + hotkeys(arrowKeys.join(','), { element: container, keydown: true }, (evt) => { + if (!evt.repeat) { + currentTarget = undefined; + selection.clear(); } - selectAllCaptured = false; }); const destroy = () => { From 34b3585be8a460a833526262d38d7b47e089e872 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Mon, 19 Aug 2024 14:21:57 +0300 Subject: [PATCH 14/58] Made popup appear on `keydown` --- packages/text-annotator-react/src/TextAnnotatorPopup.tsx | 2 +- packages/text-annotator-react/test/App.tsx | 7 ------- packages/text-annotator-react/test/tei/App.tsx | 7 ------- 3 files changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index 0fe3c96a..a68d4b09 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -63,7 +63,7 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { useEffect(() => { // Ignore all selection changes except those accompanied by a user event. if (event) { - setOpen(selected.length > 0 && (event.type === 'pointerup' || event.type === 'keyup')); + setOpen(selected.length > 0 && (event.type === 'pointerup' || event.type === 'keydown')); } }, [event, selectedKey]); diff --git a/packages/text-annotator-react/test/App.tsx b/packages/text-annotator-react/test/App.tsx index 39231293..19cd1145 100644 --- a/packages/text-annotator-react/test/App.tsx +++ b/packages/text-annotator-react/test/App.tsx @@ -22,13 +22,6 @@ const TestPopup = (props: TextAnnotatorPopupProps) => { anno.cancelSelected(); }; - useEffect(() => { - const { current: inputEl } = inputRef; - if (!inputEl) return; - - setTimeout(() => inputEl.focus({ preventScroll: true })); - }, []); - return (
diff --git a/packages/text-annotator-react/test/tei/App.tsx b/packages/text-annotator-react/test/tei/App.tsx index 8a187081..628a8d7f 100644 --- a/packages/text-annotator-react/test/tei/App.tsx +++ b/packages/text-annotator-react/test/tei/App.tsx @@ -24,13 +24,6 @@ const TestPopup = (props: TextAnnotatorPopupProps) => { anno.cancelSelected(); }; - useEffect(() => { - const { current: inputEl } = inputRef; - if (!inputEl) return; - - setTimeout(() => inputEl.focus({ preventScroll: true })); - }, []); - return (
From 3442d4b64c269a98d63ceeccfe2affa1c68169b3 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Mon, 19 Aug 2024 14:43:14 +0300 Subject: [PATCH 15/58] Added closing of the popup on selection cleanup --- .../text-annotator-react/src/TextAnnotatorPopup.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index a68d4b09..6bfec411 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -62,10 +62,15 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { useEffect(() => { // Ignore all selection changes except those accompanied by a user event. - if (event) { - setOpen(selected.length > 0 && (event.type === 'pointerup' || event.type === 'keydown')); + if (selected.length > 0 && event) { + setOpen(event.type === 'pointerup' || event.type === 'keydown'); } - }, [event, selectedKey]); + + // Close the popup if the selection is cleared + if (selected.length === 0 && isOpen) { + setOpen(false); + } + }, [isOpen, event, selectedKey]); useEffect(() => { if (!isOpen || !annotation) return; From bf6ef6f64784312e83cddab316ff9aa581309c59 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Mon, 19 Aug 2024 16:18:19 +0300 Subject: [PATCH 16/58] Added focusing on the popup content on `pointerup` event --- .../src/TextAnnotatorPopup.tsx | 23 ++++++++++++++----- packages/text-annotator-react/test/App.tsx | 13 +++++++++-- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index 6bfec411..cdfa543b 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useCallback, useEffect, useState, PointerEvent } from 'react'; +import { ReactNode, useCallback, useEffect, useState, PointerEvent, useRef, MutableRefObject } from 'react'; import { useAnnotator, useSelection } from '@annotorious/react'; import { type TextAnnotation, type TextAnnotator } from '@recogito/text-annotator'; import { @@ -19,7 +19,9 @@ interface TextAnnotationPopupProps { } -export interface TextAnnotatorPopupProps { +export interface TextAnnotatorPopupProps { + + ref: MutableRefObject; selected: { annotation: TextAnnotation, editable?: boolean }[]; @@ -53,13 +55,10 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { }); 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 user event. if (selected.length > 0 && event) { @@ -107,6 +106,18 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { } }, [update]); + const popupContentRef = useRef(null); + + useEffect(() => { + const { current: popupContent } = popupContentRef; + if (!popupContent) + return; + + if (isOpen && event?.type === 'pointerup') { + popupContent.focus(); + } + }, [isOpen, event?.type]); + return isOpen && selected.length > 0 ? (
{ style={floatingStyles} {...getFloatingProps()} {...getStopEventsPropagationProps()}> - {props.popup({ selected })} + {props.popup({ ref: popupContentRef, selected })}
) : null; diff --git a/packages/text-annotator-react/test/App.tsx b/packages/text-annotator-react/test/App.tsx index 19cd1145..43ca2aad 100644 --- a/packages/text-annotator-react/test/App.tsx +++ b/packages/text-annotator-react/test/App.tsx @@ -1,15 +1,24 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { AnnotationBody, Annotorious, useAnnotationStore, useAnnotator } from '@annotorious/react'; +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; import { TextAnnotator, TextAnnotatorPopup, TextAnnotatorPopupProps } from '../src'; import { TextAnnotation, TextAnnotator as RecogitoTextAnnotator, W3CTextFormat } from '@recogito/text-annotator'; -const TestPopup = (props: TextAnnotatorPopupProps) => { +const TestPopup = forwardRef< + Pick, + TextAnnotatorPopupProps +>((props, ref) => { const store = useAnnotationStore(); const anno = useAnnotator(); const inputRef = useRef(null); + useImperativeHandle(ref, () => ({ + focus: (options) => inputRef.current?.focus(options || { preventScroll: true }) + }), []); + + const body: AnnotationBody = { id: `${Math.random()}`, annotation: props.selected[0].annotation.id, @@ -29,7 +38,7 @@ const TestPopup = (props: TextAnnotatorPopupProps) => {
); -}; +}); const MockStorage = () => { From 9df1a89dd649ce9f9702f0d6d67ba20dbea34f6c Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Mon, 19 Aug 2024 18:34:29 +0300 Subject: [PATCH 17/58] Made the `TextAnnotatorPopup` handle the focus for the popup content --- .../src/TextAnnotatorPopup.tsx | 93 +++++++++++++++---- packages/text-annotator-react/test/App.tsx | 14 +-- packages/text-annotator-react/test/index.html | 2 +- 3 files changed, 80 insertions(+), 29 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index cdfa543b..e192d7a0 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -1,4 +1,13 @@ -import { ReactNode, useCallback, useEffect, useState, PointerEvent, useRef, MutableRefObject } from 'react'; +import { + ReactNode, + useCallback, + useEffect, + useState, + PointerEvent, + useRef, + MutableRefObject, + CSSProperties +} from 'react'; import { useAnnotator, useSelection } from '@annotorious/react'; import { type TextAnnotation, type TextAnnotator } from '@recogito/text-annotator'; import { @@ -12,22 +21,27 @@ import { useInteractions, useRole } from '@floating-ui/react'; +import { createPortal } from 'react-dom'; + +interface TextAnnotationPopupProps { -interface TextAnnotationPopupProps { + focusMessage?: string; - popup(props: TextAnnotatorPopupProps): ReactNode; + popup(props: TextAnnotatorPopupProps): ReactNode; } -export interface TextAnnotatorPopupProps { +export interface TextAnnotatorPopupProps { - ref: MutableRefObject; + ref: MutableRefObject; selected: { annotation: TextAnnotation, editable?: boolean }[]; } -export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { +export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { + + const { popup, focusMessage } = props; const r = useAnnotator(); @@ -106,27 +120,72 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { } }, [update]); - const popupContentRef = useRef(null); + const popupContentRef = useRef(null); + const popupAnnouncerRef = useRef(null); useEffect(() => { const { current: popupContent } = popupContentRef; if (!popupContent) return; - if (isOpen && event?.type === 'pointerup') { + if (!isOpen || !event) + return; + + let announcementTimout: ReturnType; + + if (event.type === 'pointerup') { + /** + * When the selection "finishes" using the pointer device, + * we can immediately focus on the popup content. + * That's useful to quickly write down a note or pick a highlight color + */ popupContent.focus(); + } else if (event.type === 'keydown') { + announcementTimout = setTimeout(() => { + /** + * When the selection is performed using the keyboard, there's no certain "finished" state. + * Even after we show the popup, users might want to continue selecting text. + * Therefore, we shouldn't shift the focus from the page, + * but make a recommendation on how to navigate to the popup. + */ + const { current: popupAnnouncer } = popupAnnouncerRef; + if (popupAnnouncer) { + popupAnnouncer.textContent = focusMessage; + } + }, 25) } - }, [isOpen, event?.type]); + + return () => { + clearTimeout(announcementTimout); + } + }, [isOpen, event]); return isOpen && selected.length > 0 ? ( -
- {props.popup({ ref: popupContentRef, selected })} -
+ <> +
+ {popup({ ref: popupContentRef, selected })} +
+ {focusMessage && createPortal( + , + document.body + )} + ) : null; } + +const visuallyHiddenStyles: CSSProperties = { + position: 'absolute', + height: '1px', + width: '1px', + clipPath: 'inset(50%)', + overflow: 'hidden', + whiteSpace: 'nowrap', + border: 0, + padding: 0, +}; diff --git a/packages/text-annotator-react/test/App.tsx b/packages/text-annotator-react/test/App.tsx index 43ca2aad..aed1b89a 100644 --- a/packages/text-annotator-react/test/App.tsx +++ b/packages/text-annotator-react/test/App.tsx @@ -1,6 +1,5 @@ -import React, { useCallback, useEffect, useRef } from 'react'; -import { AnnotationBody, Annotorious, useAnnotationStore, useAnnotator } from '@annotorious/react'; -import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; +import React, { FC, forwardRef, useCallback, useEffect } from 'react'; +import { AnnotationBody, Annotorious, useAnnotationStore, useAnnotator, useSelection } from '@annotorious/react'; import { TextAnnotator, TextAnnotatorPopup, TextAnnotatorPopupProps } from '../src'; import { TextAnnotation, TextAnnotator as RecogitoTextAnnotator, W3CTextFormat } from '@recogito/text-annotator'; @@ -12,13 +11,6 @@ const TestPopup = forwardRef< const store = useAnnotationStore(); const anno = useAnnotator(); - const inputRef = useRef(null); - - useImperativeHandle(ref, () => ({ - focus: (options) => inputRef.current?.focus(options || { preventScroll: true }) - }), []); - - const body: AnnotationBody = { id: `${Math.random()}`, annotation: props.selected[0].annotation.id, @@ -33,7 +25,7 @@ const TestPopup = forwardRef< return (
- +
); diff --git a/packages/text-annotator-react/test/index.html b/packages/text-annotator-react/test/index.html index f0508218..3528f1c0 100644 --- a/packages/text-annotator-react/test/index.html +++ b/packages/text-annotator-react/test/index.html @@ -50,4 +50,4 @@ - \ No newline at end of file + From b2b25c1cddf4047e8b574075724061c9f82edd4e Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 20 Aug 2024 12:56:32 +0300 Subject: [PATCH 18/58] Added focus message supply --- packages/text-annotator-react/test/App.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/text-annotator-react/test/App.tsx b/packages/text-annotator-react/test/App.tsx index aed1b89a..c080cb06 100644 --- a/packages/text-annotator-react/test/App.tsx +++ b/packages/text-annotator-react/test/App.tsx @@ -3,17 +3,18 @@ import { AnnotationBody, Annotorious, useAnnotationStore, useAnnotator, useSelec import { TextAnnotator, TextAnnotatorPopup, TextAnnotatorPopupProps } from '../src'; import { TextAnnotation, TextAnnotator as RecogitoTextAnnotator, W3CTextFormat } from '@recogito/text-annotator'; -const TestPopup = forwardRef< - Pick, - TextAnnotatorPopupProps +const TestPopup= forwardRef< + HTMLInputElement, TextAnnotatorPopupProps >((props, ref) => { + const { selected } = props; + const store = useAnnotationStore(); const anno = useAnnotator(); const body: AnnotationBody = { id: `${Math.random()}`, - annotation: props.selected[0].annotation.id, + annotation: selected[0].annotation.id, purpose: 'commenting', value: 'A Dummy Comment' }; @@ -32,7 +33,7 @@ const TestPopup = forwardRef< }); -const MockStorage = () => { +const MockStorage: FC = () => { const anno = useAnnotator(); @@ -63,7 +64,7 @@ const MockStorage = () => { }; -export const App = () => { +export const App: FC = () => { const w3cAdapter = useCallback((container: HTMLElement) => W3CTextFormat('https://www.gutenberg.org', container), []); return ( @@ -190,7 +191,8 @@ export const App = () => {

- + focusMessage="Press Tab to move to the note editing dialog" popup={ props => () } From d56359c3a66930728554b04cfd4a84fcc78333d3 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 20 Aug 2024 13:18:29 +0300 Subject: [PATCH 19/58] Added automatic focusing on the popup with the focus manager --- .../src/TextAnnotatorPopup.tsx | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index 6bfec411..0ff3ed2c 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -10,7 +10,7 @@ import { useDismiss, useFloating, useInteractions, - useRole + useRole, FloatingPortal, FloatingFocusManager } from '@floating-ui/react'; interface TextAnnotationPopupProps { @@ -65,12 +65,14 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { 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, event, selectedKey]); + }, [isOpen, selectedKey]); useEffect(() => { if (!isOpen || !annotation) return; @@ -108,14 +110,21 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { }, [update]); return isOpen && selected.length > 0 ? ( -
- {props.popup({ selected })} -
+ + +
+ {props.popup({ selected })} +
+
+
) : null; } From 5e4944b35a8b1c6171e5b967f697a7e977666e92 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 20 Aug 2024 14:15:21 +0300 Subject: [PATCH 20/58] Removed unused `ts-key-enum` --- package-lock.json | 7 ------- packages/text-annotator/package.json | 1 - packages/text-annotator/src/SelectionHandler.ts | 1 - 3 files changed, 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 398c9207..4f2222bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3848,12 +3848,6 @@ "node": ">=18" } }, - "node_modules/ts-key-enum": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/ts-key-enum/-/ts-key-enum-2.0.12.tgz", - "integrity": "sha512-Ety4IvKMaeG34AyXMp5r11XiVZNDRL+XWxXbVVJjLvq2vxKRttEANBE7Za1bxCAZRdH2/sZT6jFyyTWxXz28hw==", - "license": "MIT" - }, "node_modules/tsconfck": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.1.tgz", @@ -4388,7 +4382,6 @@ "dequal": "^2.0.3", "hotkeys-js": "^3.13.7", "rbush": "^4.0.0", - "ts-key-enum": "^2.0.12", "uuid": "^10.0.0" }, "devDependencies": { diff --git a/packages/text-annotator/package.json b/packages/text-annotator/package.json index f41ae275..eea7c937 100644 --- a/packages/text-annotator/package.json +++ b/packages/text-annotator/package.json @@ -42,7 +42,6 @@ "dequal": "^2.0.3", "hotkeys-js": "^3.13.7", "rbush": "^4.0.0", - "ts-key-enum": "^2.0.12", "uuid": "^10.0.0" } } diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index d4caf347..4a800c2c 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -1,7 +1,6 @@ import { Filter, Origin, type Selection, type User } from '@annotorious/core'; import { v4 as uuidv4 } from 'uuid'; import hotkeys from 'hotkeys-js'; -import { Key } from 'ts-key-enum'; import type { TextAnnotatorState } from './state'; import type { TextAnnotationTarget } from './model'; import { From c8159a151c13eb76cc80c51209791bd7209ef3ba Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 20 Aug 2024 14:16:19 +0300 Subject: [PATCH 21/58] Fixed arrow button not dismissing selection, when the focus isn't on the container --- packages/text-annotator/src/SelectionHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 4a800c2c..7141e4ee 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -205,7 +205,7 @@ export const SelectionHandler = ( }); // Free caret movement through the text resets the annotation selection - hotkeys(arrowKeys.join(','), { element: container, keydown: true }, (evt) => { + hotkeys(arrowKeys.join(','), { keydown: true }, (evt) => { if (!evt.repeat) { currentTarget = undefined; selection.clear(); From 4b6cbc2990a114a3df450cf7c28e2735761bd3a4 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 20 Aug 2024 18:05:12 +0300 Subject: [PATCH 22/58] Disabled autofocus for the keyboard selection --- packages/text-annotator-react/src/TextAnnotatorPopup.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index 0ff3ed2c..1d85be41 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -53,9 +53,7 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { }); const dismiss = useDismiss(context); - const role = useRole(context, { role: 'tooltip' }); - const { getFloatingProps } = useInteractions([dismiss, role]); const selectedKey = selected.map(a => a.annotation.id).join('-'); @@ -113,6 +111,13 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => {
Date: Tue, 20 Aug 2024 18:07:23 +0300 Subject: [PATCH 23/58] Named `useAnnotator` result consistently --- packages/text-annotator-react/test/App.tsx | 26 +++++++++---------- .../text-annotator-react/test/tei/App.tsx | 18 ++++++------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/text-annotator-react/test/App.tsx b/packages/text-annotator-react/test/App.tsx index 19cd1145..c87b15d5 100644 --- a/packages/text-annotator-react/test/App.tsx +++ b/packages/text-annotator-react/test/App.tsx @@ -6,7 +6,7 @@ import { TextAnnotation, TextAnnotator as RecogitoTextAnnotator, W3CTextFormat } const TestPopup = (props: TextAnnotatorPopupProps) => { const store = useAnnotationStore(); - const anno = useAnnotator(); + const r = useAnnotator(); const inputRef = useRef(null); @@ -19,7 +19,7 @@ const TestPopup = (props: TextAnnotatorPopupProps) => { const onClick = () => { store.addBody(body); - anno.cancelSelected(); + r.cancelSelected(); }; return ( @@ -33,30 +33,30 @@ const TestPopup = (props: TextAnnotatorPopupProps) => { const MockStorage = () => { - 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; diff --git a/packages/text-annotator-react/test/tei/App.tsx b/packages/text-annotator-react/test/tei/App.tsx index 628a8d7f..0fc91237 100644 --- a/packages/text-annotator-react/test/tei/App.tsx +++ b/packages/text-annotator-react/test/tei/App.tsx @@ -8,7 +8,7 @@ import { TEIAnnotator, CETEIcean, TextAnnotatorPopup, TextAnnotatorPopupProps } const TestPopup = (props: TextAnnotatorPopupProps) => { const store = useAnnotationStore(); - const anno = useAnnotator(); + const r = useAnnotator(); const inputRef = useRef(null); @@ -21,7 +21,7 @@ const TestPopup = (props: TextAnnotatorPopupProps) => { const onClick = () => { store.addBody(body); - anno.cancelSelected(); + r.cancelSelected(); }; return ( @@ -35,27 +35,27 @@ const TestPopup = (props: TextAnnotatorPopupProps) => { 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; From d6609a6beab53bec4d82d3a22e77d760db9a8d99 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 20 Aug 2024 20:05:33 +0300 Subject: [PATCH 24/58] Removed unused `TextAnnotatorPopup` props --- packages/text-annotator-react/test/App.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/text-annotator-react/test/App.tsx b/packages/text-annotator-react/test/App.tsx index 9e766b87..07a200ef 100644 --- a/packages/text-annotator-react/test/App.tsx +++ b/packages/text-annotator-react/test/App.tsx @@ -191,8 +191,7 @@ export const App: FC = () => {

- - focusMessage="Press Tab to move to the note editing dialog" + () } From e0bb05f5efff40e41409ad3d79c663ffa3a150fe Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 20 Aug 2024 20:36:44 +0300 Subject: [PATCH 25/58] Made the popup non-modal --- packages/text-annotator-react/src/TextAnnotatorPopup.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index 922e0999..29716fa3 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -118,6 +118,8 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { { */ event?.type === 'keydown' ? -1 : 0 } - visuallyHiddenDismiss="Dismiss the annotation dialog" >
Date: Tue, 20 Aug 2024 22:26:11 +0300 Subject: [PATCH 26/58] Added caret position restoration upon closing the popup --- .../src/TextAnnotatorPopup.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index 29716fa3..9d2e9ba1 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState, + useRef, PointerEvent } from 'react'; import { useAnnotator, useSelection } from '@annotorious/react'; @@ -114,6 +115,8 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { } }, [update]); + useRestoreSelectionCaret({ floatingOpen: isOpen }); + return isOpen && selected.length > 0 ? ( { ) : null; } + +export const useRestoreSelectionCaret = (args: { floatingOpen: boolean }) => { + const { floatingOpen } = args; + + const focusNodeRef = useRef(null); + const focusOffsetRef = useRef(null); + + useEffect(() => { + if (!floatingOpen) return; + + const sel = document.getSelection(); + focusNodeRef.current = sel?.focusNode; + focusOffsetRef.current = sel?.focusOffset; + + + console.log('Save selection', sel.focusOffset, sel.anchorOffset); + }, [floatingOpen]); + + useEffect(() => { + if (floatingOpen) return; + + const { current: focusNode } = focusNodeRef; + const { current: focusOffset } = focusOffsetRef; + if (!focusNode) return; + + + setTimeout(() => { + /** + * Restore the caret only after it got lost and automatically moved to the `body`. + * It happens when user clicks on the close button within the floating element. + */ + const sel = document.getSelection(); + if (sel && sel.isCollapsed && sel.anchorNode === document.body) { + sel.removeAllRanges(); + sel.setPosition( + focusNode, + focusOffset + 1 // Select after the last letter + ); + } + }); + }, [floatingOpen]); +}; From 565350216a1b6e2a9d7d485ef3bfe2c2c14846a1 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 20 Aug 2024 22:45:29 +0300 Subject: [PATCH 27/58] Made the caret restored on the up-to-date position --- .../src/TextAnnotatorPopup.tsx | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index 9d2e9ba1..f8d4a9ad 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -148,27 +148,28 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { export const useRestoreSelectionCaret = (args: { floatingOpen: boolean }) => { const { floatingOpen } = args; - const focusNodeRef = useRef(null); - const focusOffsetRef = useRef(null); + const { selected } = useSelection(); + const annotation = selected[0]?.annotation; + + const focusNodeRef = useRef(null); + const focusOffsetRef = useRef(null); useEffect(() => { - if (!floatingOpen) return; + if (!floatingOpen || !annotation) return; const sel = document.getSelection(); - focusNodeRef.current = sel?.focusNode; - focusOffsetRef.current = sel?.focusOffset; - - - console.log('Save selection', sel.focusOffset, sel.anchorOffset); - }, [floatingOpen]); + if (sel) { + focusNodeRef.current = sel.focusNode; + focusOffsetRef.current = sel.focusOffset; + } + }, [floatingOpen, annotation]); useEffect(() => { if (floatingOpen) return; - const { current: focusNode } = focusNodeRef; - const { current: focusOffset } = focusOffsetRef; - if (!focusNode) return; - + const focusNode = focusNodeRef.current; + const focusOffset = focusOffsetRef.current; + if (focusNode === null || focusOffset === null) return; setTimeout(() => { /** @@ -178,10 +179,7 @@ export const useRestoreSelectionCaret = (args: { floatingOpen: boolean }) => { const sel = document.getSelection(); if (sel && sel.isCollapsed && sel.anchorNode === document.body) { sel.removeAllRanges(); - sel.setPosition( - focusNode, - focusOffset + 1 // Select after the last letter - ); + sel.setPosition(focusNode, focusOffset); } }); }, [floatingOpen]); From 22481b1676eb0c51275d6dd6b0ddf5b815a501c9 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 20 Aug 2024 22:52:49 +0300 Subject: [PATCH 28/58] Removed unused ref propagation --- packages/text-annotator-react/test/App.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/text-annotator-react/test/App.tsx b/packages/text-annotator-react/test/App.tsx index 07a200ef..02cdb136 100644 --- a/packages/text-annotator-react/test/App.tsx +++ b/packages/text-annotator-react/test/App.tsx @@ -1,11 +1,9 @@ -import React, { FC, forwardRef, useCallback, useEffect } from 'react'; +import React, { FC, useCallback, useEffect } from 'react'; import { AnnotationBody, Annotorious, useAnnotationStore, useAnnotator, useSelection } from '@annotorious/react'; import { TextAnnotator, TextAnnotatorPopup, TextAnnotatorPopupProps } from '../src'; import { TextAnnotation, TextAnnotator as RecogitoTextAnnotator, W3CTextFormat } from '@recogito/text-annotator'; -const TestPopup= forwardRef< - HTMLInputElement, TextAnnotatorPopupProps ->((props, ref) => { +const TestPopup: FC = (props) => { const { selected } = props; @@ -26,12 +24,12 @@ const TestPopup= forwardRef< return (
- +
); -}); +} const MockStorage: FC = () => { From b86b86d9d4a368c62ffef0f343d13f86cc83dae4 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 20 Aug 2024 22:53:48 +0300 Subject: [PATCH 29/58] Added selection caret restoration hook --- .../src/TextAnnotatorPopup.tsx | 64 +++---------------- .../text-annotator-react/src/hooks/index.ts | 1 + .../src/hooks/useRestoreSelectionCaret.ts | 44 +++++++++++++ packages/text-annotator-react/src/index.ts | 1 + 4 files changed, 56 insertions(+), 54 deletions(-) create mode 100644 packages/text-annotator-react/src/hooks/index.ts create mode 100644 packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index f8d4a9ad..b4d90c56 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -1,27 +1,23 @@ -import { - ReactNode, - useCallback, - useEffect, - useState, - useRef, - PointerEvent -} from 'react'; -import { useAnnotator, useSelection } from '@annotorious/react'; -import { type TextAnnotation, type TextAnnotator } from '@recogito/text-annotator'; +import { PointerEvent, ReactNode, useCallback, useEffect, useState } from 'react'; import { autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, inline, offset, - flip, shift, useDismiss, useFloating, useInteractions, - useRole, - FloatingPortal, - FloatingFocusManager + useRole } from '@floating-ui/react'; +import { useAnnotator, useSelection } from '@annotorious/react'; +import type { TextAnnotation, TextAnnotator } from '@recogito/text-annotator'; + +import { useRestoreSelectionCaret } from './hooks'; + interface TextAnnotationPopupProps { popup(props: TextAnnotatorPopupProps): ReactNode; @@ -144,43 +140,3 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { ) : null; } - -export const useRestoreSelectionCaret = (args: { floatingOpen: boolean }) => { - const { floatingOpen } = args; - - const { selected } = useSelection(); - const annotation = selected[0]?.annotation; - - const focusNodeRef = useRef(null); - const focusOffsetRef = useRef(null); - - useEffect(() => { - if (!floatingOpen || !annotation) return; - - const sel = document.getSelection(); - if (sel) { - focusNodeRef.current = sel.focusNode; - focusOffsetRef.current = sel.focusOffset; - } - }, [floatingOpen, annotation]); - - useEffect(() => { - if (floatingOpen) return; - - const focusNode = focusNodeRef.current; - const focusOffset = focusOffsetRef.current; - if (focusNode === null || focusOffset === null) return; - - setTimeout(() => { - /** - * Restore the caret only after it got lost and automatically moved to the `body`. - * It happens when user clicks on the close button within the floating element. - */ - const sel = document.getSelection(); - if (sel && sel.isCollapsed && sel.anchorNode === document.body) { - sel.removeAllRanges(); - sel.setPosition(focusNode, focusOffset); - } - }); - }, [floatingOpen]); -}; diff --git a/packages/text-annotator-react/src/hooks/index.ts b/packages/text-annotator-react/src/hooks/index.ts new file mode 100644 index 00000000..61a73d75 --- /dev/null +++ b/packages/text-annotator-react/src/hooks/index.ts @@ -0,0 +1 @@ +export { useRestoreSelectionCaret } from './useRestoreSelectionCaret'; diff --git a/packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts b/packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts new file mode 100644 index 00000000..664ac3a1 --- /dev/null +++ b/packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef } from 'react'; + +import { useSelection } from '@annotorious/react'; +import type { TextAnnotation } from '@recogito/text-annotator'; + +export const useRestoreSelectionCaret = (args: { floatingOpen: boolean }) => { + const { floatingOpen } = args; + + const { selected } = useSelection(); + const annotation = selected[0]?.annotation; + + const focusNodeRef = useRef(null); + const focusOffsetRef = useRef(null); + + useEffect(() => { + if (!floatingOpen || !annotation) return; + + const sel = document.getSelection(); + if (sel) { + focusNodeRef.current = sel.focusNode; + focusOffsetRef.current = sel.focusOffset; + } + }, [floatingOpen, annotation]); + + useEffect(() => { + if (floatingOpen) return; + + const focusNode = focusNodeRef.current; + const focusOffset = focusOffsetRef.current; + if (focusNode === null || focusOffset === null) return; + + setTimeout(() => { + /** + * Restore the caret only after it got lost and automatically moved to the `body`. + * It happens when user clicks on the close button within the floating element. + */ + const sel = document.getSelection(); + if (sel && sel.isCollapsed && sel.anchorNode === document.body) { + sel.removeAllRanges(); + sel.setPosition(focusNode, focusOffset); + } + }); + }, [floatingOpen]); +}; diff --git a/packages/text-annotator-react/src/index.ts b/packages/text-annotator-react/src/index.ts index 645d635f..1c1ab44b 100644 --- a/packages/text-annotator-react/src/index.ts +++ b/packages/text-annotator-react/src/index.ts @@ -1,4 +1,5 @@ export * from './tei'; +export * from './hooks'; export * from './TextAnnotator'; export * from './TextAnnotatorPopup'; export * from './TextAnnotatorPlugin'; From a3b589ce3af9f5092f2343bbc8717c46f619008c Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 21 Aug 2024 12:46:45 +0300 Subject: [PATCH 30/58] Fixed jumping viewport on focus return --- packages/text-annotator-react/src/TextAnnotatorPopup.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index b4d90c56..e89b42ac 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -126,6 +126,7 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { */ event?.type === 'keydown' ? -1 : 0 } + returnFocus={false} >
Date: Wed, 21 Aug 2024 13:07:07 +0300 Subject: [PATCH 31/58] Made the caret restored on the selection range start --- .../src/hooks/useRestoreSelectionCaret.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts b/packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts index 664ac3a1..ea313cd3 100644 --- a/packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts +++ b/packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts @@ -3,41 +3,43 @@ import { useEffect, useRef } from 'react'; import { useSelection } from '@annotorious/react'; import type { TextAnnotation } from '@recogito/text-annotator'; +/** + * Restores the caret position after the floating element gets closed + * and the selection is lost and moved to the `body`. + * The caret is placed at the start of the previous selection range. + * + * However, when the floating element is dismissed with intentional + * caret repositioning via the mouse click or arrow key navigation -> + * we shouldn't restore its position to the previous selection. + */ export const useRestoreSelectionCaret = (args: { floatingOpen: boolean }) => { const { floatingOpen } = args; const { selected } = useSelection(); const annotation = selected[0]?.annotation; - const focusNodeRef = useRef(null); - const focusOffsetRef = useRef(null); + const selectionRangeRef = useRef(null); useEffect(() => { if (!floatingOpen || !annotation) return; const sel = document.getSelection(); if (sel) { - focusNodeRef.current = sel.focusNode; - focusOffsetRef.current = sel.focusOffset; + selectionRangeRef.current = sel.getRangeAt(0).cloneRange(); } }, [floatingOpen, annotation]); useEffect(() => { if (floatingOpen) return; - const focusNode = focusNodeRef.current; - const focusOffset = focusOffsetRef.current; - if (focusNode === null || focusOffset === null) return; + const { current: selectionRange } = selectionRangeRef; + if (!selectionRange) return; setTimeout(() => { - /** - * Restore the caret only after it got lost and automatically moved to the `body`. - * It happens when user clicks on the close button within the floating element. - */ const sel = document.getSelection(); if (sel && sel.isCollapsed && sel.anchorNode === document.body) { sel.removeAllRanges(); - sel.setPosition(focusNode, focusOffset); + sel.setPosition(selectionRange.startContainer, selectionRange.startOffset); } }); }, [floatingOpen]); From 94b182fb6753b284b8af60ba89161cadcf902b69 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 21 Aug 2024 15:52:43 +0300 Subject: [PATCH 32/58] Limited arrow listener only to the annotatable container --- packages/text-annotator/src/SelectionHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 7141e4ee..a8b260b1 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -205,7 +205,7 @@ export const SelectionHandler = ( }); // Free caret movement through the text resets the annotation selection - hotkeys(arrowKeys.join(','), { keydown: true }, (evt) => { + hotkeys(arrowKeys.join(','), { element: container, keydown: true, keyup: false }, (evt) => { if (!evt.repeat) { currentTarget = undefined; selection.clear(); From 66ede9f506219e44a5b9aac0d64fb7597f233b32 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 21 Aug 2024 15:53:38 +0300 Subject: [PATCH 33/58] Added focusing on the content upon restoring the caret --- .../src/hooks/useRestoreSelectionCaret.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts b/packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts index ea313cd3..5c08f8c5 100644 --- a/packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts +++ b/packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts @@ -35,11 +35,18 @@ export const useRestoreSelectionCaret = (args: { floatingOpen: boolean }) => { const { current: selectionRange } = selectionRangeRef; if (!selectionRange) return; + const { startContainer, startOffset } = selectionRange; + setTimeout(() => { const sel = document.getSelection(); if (sel && sel.isCollapsed && sel.anchorNode === document.body) { sel.removeAllRanges(); - sel.setPosition(selectionRange.startContainer, selectionRange.startOffset); + sel.setPosition(startContainer, startOffset); + + const startContainerElement = startContainer instanceof HTMLElement + ? startContainer + : startContainer.parentElement; + startContainerElement.focus({ preventScroll: true }); } }); }, [floatingOpen]); From 6afa6fe232c3b5918f8dbebe9bb56ad7e137b610 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 21 Aug 2024 17:54:34 +0300 Subject: [PATCH 34/58] Added selection discard on focus leaving the popup --- .../src/TextAnnotatorPopup.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx index e89b42ac..2696eb32 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup.tsx @@ -42,10 +42,21 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { const { refs, floatingStyles, update, context } = useFloating({ placement: 'top', open: isOpen, - onOpenChange: (open, _event, reason) => { + onOpenChange: (open, event, reason) => { setOpen(open); - if (!open && reason === 'escape-key') { - r?.cancelSelected(); + + if (!open) { + if ( + reason === 'escape-key' || + /** + * When the focus leaves the floating - cancel the selection. + * However, it doesn't have a distinct reason yet, will be resolved in the discussion: + * @see https://github.com/floating-ui/floating-ui/discussions/3012#discussioncomment-10405906 + */ + event instanceof FocusEvent + ) { + r?.cancelSelected(); + } } }, middleware: [ @@ -118,7 +129,7 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { Date: Wed, 21 Aug 2024 19:42:24 +0300 Subject: [PATCH 35/58] Added sr-only popup close message --- .../TextAnnotatorPopup/TextAnnotatorPopup.css | 30 +++++++++++++++++++ .../TextAnnotatorPopup.tsx | 16 ++++++++-- .../src/TextAnnotatorPopup/index.ts | 1 + 3 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.css rename packages/text-annotator-react/src/{ => TextAnnotatorPopup}/TextAnnotatorPopup.tsx (88%) create mode 100644 packages/text-annotator-react/src/TextAnnotatorPopup/index.ts 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.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx similarity index 88% rename from packages/text-annotator-react/src/TextAnnotatorPopup.tsx rename to packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx index 2696eb32..98f37570 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx @@ -1,4 +1,4 @@ -import { PointerEvent, ReactNode, useCallback, useEffect, useState } from 'react'; +import React, { PointerEvent, ReactNode, useCallback, useEffect, useState } from 'react'; import { autoUpdate, flip, @@ -16,7 +16,8 @@ import { import { useAnnotator, useSelection } from '@annotorious/react'; import type { TextAnnotation, TextAnnotator } from '@recogito/text-annotator'; -import { useRestoreSelectionCaret } from './hooks'; +import { useRestoreSelectionCaret } from '../hooks'; +import './TextAnnotatorPopup.css'; interface TextAnnotationPopupProps { @@ -39,6 +40,10 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { const [isOpen, setOpen] = useState(selected?.length > 0); + const handleClose = () => { + r?.cancelSelected(); + } + const { refs, floatingStyles, update, context } = useFloating({ placement: 'top', open: isOpen, @@ -69,7 +74,7 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { }); const dismiss = useDismiss(context); - const role = useRole(context, { role: 'tooltip' }); + const role = useRole(context, { role: 'dialog' }); const { getFloatingProps } = useInteractions([dismiss, role]); const selectedKey = selected.map(a => a.annotation.id).join('-'); @@ -146,6 +151,11 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { {...getFloatingProps()} {...getStopEventsPropagationProps()}> {props.popup({ selected })} + + {/* It lets keyboard/sr users to know that the dialog closes when they focus out of its */} +
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'; From 17a16063a8fd308655d189a3504bdf064de797ce Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Fri, 23 Aug 2024 14:22:25 +0300 Subject: [PATCH 36/58] Typo fix --- .../src/TextAnnotatorPopup/TextAnnotatorPopup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx index 98f37570..02ae30e2 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx @@ -152,7 +152,7 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { {...getStopEventsPropagationProps()}> {props.popup({ selected })} - {/* It lets keyboard/sr users to know that the dialog closes when they focus out of its */} + {/* It lets keyboard/sr users to know that the dialog closes when they focus out of it */} From 8aeec59d810221ece0f46653065aca0b6855fcaa Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Fri, 23 Aug 2024 19:04:51 +0300 Subject: [PATCH 37/58] Added popup rendering above the highlights --- packages/text-annotator-react/test/index.html | 3 ++- packages/text-annotator-react/test/tei/index.html | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/text-annotator-react/test/index.html b/packages/text-annotator-react/test/index.html index 3528f1c0..cf426691 100644 --- a/packages/text-annotator-react/test/index.html +++ b/packages/text-annotator-react/test/index.html @@ -24,10 +24,11 @@ line-height: 160%; } - .popup { + .annotation-popup { background-color: #fff; border: 1px solid gray; padding: 20px; + z-index: 1; } #debug { diff --git a/packages/text-annotator-react/test/tei/index.html b/packages/text-annotator-react/test/tei/index.html index 2668a382..ec568fab 100644 --- a/packages/text-annotator-react/test/tei/index.html +++ b/packages/text-annotator-react/test/tei/index.html @@ -33,10 +33,11 @@ line-height: 160%; } - .popup { + .annotation-popup { background-color: #fff; border: 1px solid gray; padding: 20px; + z-index: 1; } @@ -44,4 +45,4 @@
- \ No newline at end of file + From 501405237ca9b676c6b3bbf68616dcd66efa9d5c Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Fri, 23 Aug 2024 20:46:38 +0300 Subject: [PATCH 38/58] Moved `currentTarget` cleanup into the `pointerdown` handler --- .../text-annotator/src/SelectionHandler.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 7fddeb38..17644585 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -41,16 +41,12 @@ export const SelectionHandler = ( // 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; - } + currentTarget = annotatable ? { + annotation: uuidv4(), + selector: [], + creator: currentUser, + created: new Date() + } : undefined; } if (annotatingEnabled) @@ -136,6 +132,7 @@ export const SelectionHandler = ( lastPointerDown = { ...evt, target, timeStamp, offsetX, offsetY, type }; isLeftClick = evt.button === 0; + currentTarget = undefined; } container.addEventListener('pointerdown', onPointerDown); @@ -160,11 +157,11 @@ export const SelectionHandler = ( } } + const sel = document.getSelection(); const timeDifference = evt.timeStamp - lastPointerDown.timeStamp; // Just a click, not a selection - if (document.getSelection().isCollapsed && timeDifference < 300) { - currentTarget = undefined; + if (sel?.isCollapsed && timeDifference < 300) { clickSelect(); } else if (currentTarget) { selection.userSelect(currentTarget.annotation, evt); From 8684a8afd935b9bdf8b38017be726d6fc5ca6593 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Tue, 27 Aug 2024 21:13:03 +0300 Subject: [PATCH 39/58] Added annotation reference cleanup --- .../TextAnnotatorPopup/TextAnnotatorPopup.tsx | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx index 02ae30e2..f9748d21 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx @@ -93,18 +93,21 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { }, [isOpen, selectedKey]); useEffect(() => { - if (!isOpen || !annotation) return; - - const { - target: { - selector: [{ range }] - } - } = annotation; - - refs.setPositionReference({ - getBoundingClientRect: range.getBoundingClientRect.bind(range), - getClientRects: range.getClientRects.bind(range) - }); + 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 From 74e8e13024b7a37e46b8dc291df33058a910d6e3 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 28 Aug 2024 14:44:21 +0300 Subject: [PATCH 40/58] Bumped `floating-ui` to `^0.26.23` # See - https://github.com/floating-ui/floating-ui/releases/tag/%40floating-ui%2Freact%400.26.23 --- package-lock.json | 18 ++++++++++-------- packages/text-annotator-react/package.json | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4f2222bb..b0764c32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -748,12 +748,13 @@ } }, "node_modules/@floating-ui/react": { - "version": "0.26.20", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.20.tgz", - "integrity": "sha512-RixKJJG92fcIsVoqrFr4Onpzh7hlOx4U7NV4aLhMLmtvjZ5oTB/WzXaANYUZATKqXvvW7t9sCxtzejip26N5Ag==", + "version": "0.26.23", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.23.tgz", + "integrity": "sha512-9u3i62fV0CFF3nIegiWiRDwOs7OW/KhSUJDNx2MkQM3LbE5zQOY01sL3nelcVBXvX7Ovvo3A49I8ql+20Wg/Hw==", + "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.1.1", - "@floating-ui/utils": "^0.2.5", + "@floating-ui/utils": "^0.2.7", "tabbable": "^6.0.0" }, "peerDependencies": { @@ -774,9 +775,10 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.5.tgz", - "integrity": "sha512-sTcG+QZ6fdEUObICavU+aB3Mp8HY4n14wYHdxK4fXjPmv3PXZZeY5RaguJmGyeH/CJQhX3fqKUtS4qc1LoHwhQ==" + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.7.tgz", + "integrity": "sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==", + "license": "MIT" }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", @@ -4403,7 +4405,7 @@ "dependencies": { "@annotorious/core": "^3.0.0-rc.31", "@annotorious/react": "^3.0.0-rc.31", - "@floating-ui/react": "^0.26.20", + "@floating-ui/react": "^0.26.23", "@recogito/text-annotator": "3.0.0-rc.39", "@recogito/text-annotator-tei": "3.0.0-rc.39", "CETEIcean": "^1.9.3" diff --git a/packages/text-annotator-react/package.json b/packages/text-annotator-react/package.json index 632db0bc..a212fb67 100644 --- a/packages/text-annotator-react/package.json +++ b/packages/text-annotator-react/package.json @@ -47,7 +47,7 @@ "dependencies": { "@annotorious/core": "^3.0.0-rc.31", "@annotorious/react": "^3.0.0-rc.31", - "@floating-ui/react": "^0.26.20", + "@floating-ui/react": "^0.26.23", "@recogito/text-annotator": "3.0.0-rc.39", "@recogito/text-annotator-tei": "3.0.0-rc.39", "CETEIcean": "^1.9.3" From e6f8540164215eacbecd9717f46e2513248432bb Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 28 Aug 2024 14:47:01 +0300 Subject: [PATCH 41/58] Added `focus-out` reason listening --- .../src/TextAnnotatorPopup/TextAnnotatorPopup.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx index f9748d21..ea5b63c6 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx @@ -47,19 +47,11 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { const { refs, floatingStyles, update, context } = useFloating({ placement: 'top', open: isOpen, - onOpenChange: (open, event, reason) => { + onOpenChange: (open, _event, reason) => { setOpen(open); if (!open) { - if ( - reason === 'escape-key' || - /** - * When the focus leaves the floating - cancel the selection. - * However, it doesn't have a distinct reason yet, will be resolved in the discussion: - * @see https://github.com/floating-ui/floating-ui/discussions/3012#discussioncomment-10405906 - */ - event instanceof FocusEvent - ) { + if (reason === 'escape-key' || reason === 'focus-out') { r?.cancelSelected(); } } From 12da7824318a629dc11a81ba8858043cdd26f6b9 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 28 Aug 2024 14:47:20 +0300 Subject: [PATCH 42/58] Removed duplicated `currentTarget` cleanup --- packages/text-annotator/src/SelectionHandler.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index f79f4aa3..7e045076 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -166,7 +166,6 @@ export const SelectionHandler = ( } } - const sel = document.getSelection(); const timeDifference = evt.timeStamp - lastDownEvent.timeStamp; /** @@ -183,7 +182,6 @@ export const SelectionHandler = ( // Just a click, not a selection if (sel?.isCollapsed && timeDifference < 300) { - currentTarget = undefined; userSelect(); } else if (currentTarget) { selection.userSelect(currentTarget.annotation, evt); From 9517a9771c40ace315ef7a9fff355c1548d7b584 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 28 Aug 2024 14:48:46 +0300 Subject: [PATCH 43/58] Added the whole selection range restoration upon popup dismissal --- .../TextAnnotatorPopup/TextAnnotatorPopup.tsx | 7 +-- .../text-annotator-react/src/hooks/index.ts | 2 +- .../src/hooks/useRestoreSelectionCaret.ts | 53 ------------------- .../src/hooks/useRestoreSelectionRange.ts | 52 ++++++++++++++++++ 4 files changed, 57 insertions(+), 57 deletions(-) delete mode 100644 packages/text-annotator-react/src/hooks/useRestoreSelectionCaret.ts create mode 100644 packages/text-annotator-react/src/hooks/useRestoreSelectionRange.ts diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx index ea5b63c6..d87ef062 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx @@ -16,7 +16,7 @@ import { import { useAnnotator, useSelection } from '@annotorious/react'; import type { TextAnnotation, TextAnnotator } from '@recogito/text-annotator'; -import { useRestoreSelectionCaret } from '../hooks'; +import { useRestoreSelectionRange } from '../hooks'; import './TextAnnotatorPopup.css'; interface TextAnnotationPopupProps { @@ -44,6 +44,8 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { r?.cancelSelected(); } + const restoreSelection = useRestoreSelectionRange(); + const { refs, floatingStyles, update, context } = useFloating({ placement: 'top', open: isOpen, @@ -53,6 +55,7 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { if (!open) { if (reason === 'escape-key' || reason === 'focus-out') { r?.cancelSelected(); + restoreSelection(); } } }, @@ -122,8 +125,6 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { } }, [update]); - useRestoreSelectionCaret({ floatingOpen: isOpen }); - return isOpen && selected.length > 0 ? ( - * we shouldn't restore its position to the previous selection. - */ -export const useRestoreSelectionCaret = (args: { floatingOpen: boolean }) => { - const { floatingOpen } = args; - - const { selected } = useSelection(); - const annotation = selected[0]?.annotation; - - const selectionRangeRef = useRef(null); - - useEffect(() => { - if (!floatingOpen || !annotation) return; - - const sel = document.getSelection(); - if (sel) { - selectionRangeRef.current = sel.getRangeAt(0).cloneRange(); - } - }, [floatingOpen, annotation]); - - useEffect(() => { - if (floatingOpen) return; - - const { current: selectionRange } = selectionRangeRef; - if (!selectionRange) return; - - const { startContainer, startOffset } = selectionRange; - - setTimeout(() => { - const sel = document.getSelection(); - if (sel && sel.isCollapsed && sel.anchorNode === document.body) { - sel.removeAllRanges(); - sel.setPosition(startContainer, startOffset); - - const startContainerElement = startContainer instanceof HTMLElement - ? startContainer - : startContainer.parentElement; - startContainerElement.focus({ preventScroll: true }); - } - }); - }, [floatingOpen]); -}; diff --git a/packages/text-annotator-react/src/hooks/useRestoreSelectionRange.ts b/packages/text-annotator-react/src/hooks/useRestoreSelectionRange.ts new file mode 100644 index 00000000..f90d2a7e --- /dev/null +++ b/packages/text-annotator-react/src/hooks/useRestoreSelectionRange.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { useAnnotator, useSelection } from '@annotorious/react'; +import type { TextAnnotation, TextAnnotator } from '@recogito/text-annotator'; + +/** + * Restores the selection range after the floating element + * is dismissed and the selection is lost. + * + * However, when the floating element is dismissed with intentional + * caret repositioning via the mouse click or arrow key navigation -> + * we shouldn't restore its position to the previous selection. + */ +export const useRestoreSelectionRange = () => { + const r = useAnnotator(); + + const { selected } = useSelection(); + const annotation = selected[0]?.annotation; + + const selectionRangeRef = useRef(null); + + useEffect(() => { + if (!annotation?.target) return; + + const sel = document.getSelection(); + selectionRangeRef.current = sel?.getRangeAt(0).cloneRange() || null; + }, [annotation?.target]); + + return useCallback(() => { + const { current: selectionRange } = selectionRangeRef; + if (!selectionRange) return; + + setTimeout(() => { + const sel = document.getSelection(); + if (sel) { + // Temporary disable the annotating mode to prevent creation of new annotations + r?.setAnnotatingEnabled(false); + + sel.removeAllRanges(); + sel.addRange(selectionRange); + + const { startContainer } = selectionRange; + const startContainerElement = startContainer instanceof HTMLElement + ? startContainer + : startContainer.parentElement; + startContainerElement.focus({ preventScroll: true }); + + r?.setAnnotatingEnabled(true); + } + }); + }, [r]); +}; From ea1fc24ab7309d5a0c5720830939f4e4fc68d1a9 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 28 Aug 2024 18:29:31 +0300 Subject: [PATCH 44/58] Removed the selection range restoration hook --- .../TextAnnotatorPopup/TextAnnotatorPopup.tsx | 11 ++-- .../text-annotator-react/src/hooks/index.ts | 1 - .../src/hooks/useRestoreSelectionRange.ts | 52 ------------------- packages/text-annotator-react/src/index.ts | 1 - 4 files changed, 4 insertions(+), 61 deletions(-) delete mode 100644 packages/text-annotator-react/src/hooks/index.ts delete mode 100644 packages/text-annotator-react/src/hooks/useRestoreSelectionRange.ts diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx index d87ef062..da193e34 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx @@ -16,7 +16,6 @@ import { import { useAnnotator, useSelection } from '@annotorious/react'; import type { TextAnnotation, TextAnnotator } from '@recogito/text-annotator'; -import { useRestoreSelectionRange } from '../hooks'; import './TextAnnotatorPopup.css'; interface TextAnnotationPopupProps { @@ -42,9 +41,7 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { const handleClose = () => { r?.cancelSelected(); - } - - const restoreSelection = useRestoreSelectionRange(); + }; const { refs, floatingStyles, update, context } = useFloating({ placement: 'top', @@ -54,8 +51,8 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { if (!open) { if (reason === 'escape-key' || reason === 'focus-out') { + console.log('FOCUS OUT!'); r?.cancelSelected(); - restoreSelection(); } } }, @@ -122,7 +119,7 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { return () => { mutationObserver.disconnect(); window.document.removeEventListener('scroll', update, true); - } + }; }, [update]); return isOpen && selected.length > 0 ? ( @@ -157,4 +154,4 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { ) : null; -} +}; diff --git a/packages/text-annotator-react/src/hooks/index.ts b/packages/text-annotator-react/src/hooks/index.ts deleted file mode 100644 index 2a968db7..00000000 --- a/packages/text-annotator-react/src/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useRestoreSelectionRange } from './useRestoreSelectionRange'; diff --git a/packages/text-annotator-react/src/hooks/useRestoreSelectionRange.ts b/packages/text-annotator-react/src/hooks/useRestoreSelectionRange.ts deleted file mode 100644 index f90d2a7e..00000000 --- a/packages/text-annotator-react/src/hooks/useRestoreSelectionRange.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useCallback, useEffect, useRef } from 'react'; - -import { useAnnotator, useSelection } from '@annotorious/react'; -import type { TextAnnotation, TextAnnotator } from '@recogito/text-annotator'; - -/** - * Restores the selection range after the floating element - * is dismissed and the selection is lost. - * - * However, when the floating element is dismissed with intentional - * caret repositioning via the mouse click or arrow key navigation -> - * we shouldn't restore its position to the previous selection. - */ -export const useRestoreSelectionRange = () => { - const r = useAnnotator(); - - const { selected } = useSelection(); - const annotation = selected[0]?.annotation; - - const selectionRangeRef = useRef(null); - - useEffect(() => { - if (!annotation?.target) return; - - const sel = document.getSelection(); - selectionRangeRef.current = sel?.getRangeAt(0).cloneRange() || null; - }, [annotation?.target]); - - return useCallback(() => { - const { current: selectionRange } = selectionRangeRef; - if (!selectionRange) return; - - setTimeout(() => { - const sel = document.getSelection(); - if (sel) { - // Temporary disable the annotating mode to prevent creation of new annotations - r?.setAnnotatingEnabled(false); - - sel.removeAllRanges(); - sel.addRange(selectionRange); - - const { startContainer } = selectionRange; - const startContainerElement = startContainer instanceof HTMLElement - ? startContainer - : startContainer.parentElement; - startContainerElement.focus({ preventScroll: true }); - - r?.setAnnotatingEnabled(true); - } - }); - }, [r]); -}; diff --git a/packages/text-annotator-react/src/index.ts b/packages/text-annotator-react/src/index.ts index 1c1ab44b..645d635f 100644 --- a/packages/text-annotator-react/src/index.ts +++ b/packages/text-annotator-react/src/index.ts @@ -1,5 +1,4 @@ export * from './tei'; -export * from './hooks'; export * from './TextAnnotator'; export * from './TextAnnotatorPopup'; export * from './TextAnnotatorPlugin'; From 356f72e4f5fe8014a6b21bf736accb295f51b072 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 28 Aug 2024 18:40:53 +0300 Subject: [PATCH 45/58] Fixed processing of a collapsed selection on `selectionchange` event --- .../text-annotator/src/SelectionHandler.ts | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 7e045076..a21d5c53 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -54,7 +54,7 @@ export const SelectionHandler = ( creator: currentUser, created: new Date() } : undefined; - } + }; if (annotatingEnabled) container.addEventListener('selectstart', onSelectStart); @@ -77,18 +77,20 @@ export const SelectionHandler = ( } // The selection isn't active -> bail out from selection change processing - if (!currentTarget) - return; + 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); + } - /** - * 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 (sel.isCollapsed && store.getAnnotation(currentTarget.annotation)) { - selection.clear(); - store.deleteAnnotation(currentTarget.annotation); return; } @@ -125,7 +127,7 @@ export const SelectionHandler = ( // ...then make the new annotation the current selection selection.userSelect(currentTarget.annotation, lastDownEvent); } - }) + }); if (annotatingEnabled) document.addEventListener('selectionchange', onSelectionChange); @@ -143,7 +145,7 @@ export const SelectionHandler = ( lastDownEvent = clonePointerEvent(evt); isLeftClick = lastDownEvent.button === 0; currentTarget = undefined; - } + }; container.addEventListener('pointerdown', onPointerDown); const onPointerUp = (evt: PointerEvent) => { @@ -164,7 +166,7 @@ export const SelectionHandler = ( } else if (!selection.isEmpty()) { selection.clear(); } - } + }; const timeDifference = evt.timeStamp - lastDownEvent.timeStamp; @@ -178,7 +180,7 @@ 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 < 300) { @@ -187,7 +189,7 @@ export const SelectionHandler = ( selection.userSelect(currentTarget.annotation, evt); } }); - } + }; document.addEventListener('pointerup', onPointerUp); @@ -220,13 +222,13 @@ export const SelectionHandler = ( document.removeEventListener('pointerup', onPointerUp); hotkeys.unbind(); - } + }; return { destroy, setFilter, setUser - } + }; -} +}; From 3a57f75e89b1fae2645366ff099776beea8fa4ee Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Wed, 28 Aug 2024 18:49:04 +0300 Subject: [PATCH 46/58] Removed testing log --- .../src/TextAnnotatorPopup/TextAnnotatorPopup.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx index da193e34..b44360c9 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx @@ -51,7 +51,6 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { if (!open) { if (reason === 'escape-key' || reason === 'focus-out') { - console.log('FOCUS OUT!'); r?.cancelSelected(); } } From d0d14db05af8700edfbabec56835e44288525716 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Thu, 29 Aug 2024 21:11:47 +0300 Subject: [PATCH 47/58] Added arrow movement handling on the `body` --- packages/text-annotator/src/SelectionHandler.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index a21d5c53..c301f32c 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -206,13 +206,22 @@ export const SelectionHandler = ( } }); - // Free caret movement through the text resets the annotation selection - hotkeys(arrowKeys.join(','), { element: container, keydown: true, keyup: false }, (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) { currentTarget = undefined; selection.clear(); } - }); + }; + hotkeys(arrowKeys.join(','), { element: document.body, keydown: true, keyup: false }, handleArrowKeyPress); + hotkeys(arrowKeys.join(','), { element: container, keydown: true, keyup: false }, handleArrowKeyPress); const destroy = () => { container.removeEventListener('selectstart', onSelectStart); From fd74af86036b18dc991c08c558902ed01271eadf Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Thu, 29 Aug 2024 21:29:39 +0300 Subject: [PATCH 48/58] Limited arrow handling to the container & body explicitly --- packages/text-annotator/src/SelectionHandler.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index c301f32c..710edf4c 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -215,13 +215,17 @@ export const SelectionHandler = ( * or clicks the button that gets unmounted, e.g. "Close" */ const handleArrowKeyPress = (evt: KeyboardEvent) => { - if (!evt.repeat) { - currentTarget = undefined; - selection.clear(); + if ( + evt.repeat || + evt.target !== container && evt.target !== document.body + ) { + return; } + + currentTarget = undefined; + selection.clear(); }; - hotkeys(arrowKeys.join(','), { element: document.body, keydown: true, keyup: false }, handleArrowKeyPress); - hotkeys(arrowKeys.join(','), { element: container, keydown: true, keyup: false }, handleArrowKeyPress); + hotkeys(arrowKeys.join(','), { keydown: true, keyup: false }, handleArrowKeyPress); const destroy = () => { container.removeEventListener('selectstart', onSelectStart); From 690008f396a285cc62b91cb0be5557c75ef69bd2 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Mon, 2 Sep 2024 18:59:06 +0300 Subject: [PATCH 49/58] Moved `currentTarget` reset back to the `pointerup` handler --- packages/text-annotator/src/SelectionHandler.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 21757791..6488ab4a 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -11,6 +11,8 @@ import { NOT_ANNOTATABLE_SELECTOR } from './utils'; +const CLICK_TIMEOUT = 300; + export const SelectionHandler = ( container: HTMLElement, state: TextAnnotatorState, @@ -88,7 +90,7 @@ export const SelectionHandler = ( const selectionRange = sel.getRangeAt(0); -// The selection should be captured only within the annotatable container + // The selection should be captured only within the annotatable container const containedRange = trimRangeToContainer(selectionRange, container); if (isWhitespaceOrEmpty(containedRange)) return; @@ -137,7 +139,6 @@ export const SelectionHandler = ( lastPointerDown = { ...evt, target, timeStamp, offsetX, offsetY, type }; isLeftClick = evt.button === 0; - currentTarget = undefined; } document.addEventListener('pointerdown', onPointerDown); @@ -170,7 +171,8 @@ export const SelectionHandler = ( const timeDifference = evt.timeStamp - lastPointerDown.timeStamp; // Just a click, not a selection - if (sel?.isCollapsed && timeDifference < 300) { + if (sel?.isCollapsed && timeDifference < CLICK_TIMEOUT) { + currentTarget = undefined; clickSelect(); } else if (currentTarget) { selection.userSelect(currentTarget.annotation, evt); From ef3c6200f86fd5a00ae0a79729446a958dc37c52 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Mon, 2 Sep 2024 19:01:58 +0300 Subject: [PATCH 50/58] Added `selectstart` emulation when text is clicked --- .../text-annotator/src/SelectionHandler.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 6488ab4a..04e025ec 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -67,14 +67,26 @@ 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) + + if (timeDifference < 1000 && !currentTarget) { + + // Chrome/iOS does not reliably fire the 'selectstart' event! onSelectStart(lastPointerDown); + } else if (sel.isCollapsed && timeDifference < CLICK_TIMEOUT) { + + /* + Firefox doesn't fire the 'selectstart' when user clicks + over the text, which collapses the selection + */ + onSelectStart(lastPointerDown); + + } + // The selection isn't active -> bail out from selection change processing - if (!currentTarget) - return; + if (!currentTarget) return; /** * The selection range got collapsed during the selecting process. From 38ad41de43bfec446610b6833dc04ea60443fd57 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Mon, 2 Sep 2024 19:02:54 +0300 Subject: [PATCH 51/58] Added existence check before explicit reselection --- packages/text-annotator/src/SelectionHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index 04e025ec..f6897a74 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -186,7 +186,7 @@ export const SelectionHandler = ( if (sel?.isCollapsed && timeDifference < CLICK_TIMEOUT) { currentTarget = undefined; clickSelect(); - } else if (currentTarget) { + } else if (currentTarget && store.getAnnotation(currentTarget.annotation)) { selection.userSelect(currentTarget.annotation, evt); } } From dcd829191bd351865e8af6c895f1813e22255ffc Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Mon, 2 Sep 2024 20:02:37 +0300 Subject: [PATCH 52/58] Updated popup props --- .../TextAnnotatorPopup/TextAnnotatorPopup.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx index b44360c9..d001400e 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx @@ -20,13 +20,17 @@ import './TextAnnotatorPopup.css'; interface TextAnnotationPopupProps { - popup(props: TextAnnotatorPopupProps): ReactNode; + popup(props: TextAnnotationPopupContentProps): ReactNode; } -export interface TextAnnotatorPopupProps { +export interface TextAnnotationPopupContentProps { - selected: { annotation: TextAnnotation, editable?: boolean }[]; + annotation: TextAnnotation; + + editable?: boolean; + + event?: PointerEvent; } @@ -142,7 +146,11 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { style={floatingStyles} {...getFloatingProps()} {...getStopEventsPropagationProps()}> - {props.popup({ selected })} + {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 */}