diff --git a/package-lock.json b/package-lock.json index a432e9ac..6780d919 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5506,7 +5506,8 @@ "@floating-ui/react": "^0.27.1", "@recogito/text-annotator": "3.0.0-rc.53", "@recogito/text-annotator-tei": "3.0.0-rc.53", - "CETEIcean": "^1.9.3" + "CETEIcean": "^1.9.3", + "dequal": "^2.0.3" }, "devDependencies": { "@types/react-dom": "^18.3.5", diff --git a/packages/text-annotator-react/package.json b/packages/text-annotator-react/package.json index 9d56b3a2..76d153c9 100644 --- a/packages/text-annotator-react/package.json +++ b/packages/text-annotator-react/package.json @@ -49,6 +49,7 @@ "@floating-ui/react": "^0.27.1", "@recogito/text-annotator": "3.0.0-rc.53", "@recogito/text-annotator-tei": "3.0.0-rc.53", - "CETEIcean": "^1.9.3" + "CETEIcean": "^1.9.3", + "dequal": "^2.0.3" } -} \ No newline at end of file +} diff --git a/packages/text-annotator-react/src/TextAnnotationPopup/TextAnnotationPopup.tsx b/packages/text-annotator-react/src/TextAnnotationPopup/TextAnnotationPopup.tsx index f0493149..b9b4dc4a 100644 --- a/packages/text-annotator-react/src/TextAnnotationPopup/TextAnnotationPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotationPopup/TextAnnotationPopup.tsx @@ -1,4 +1,5 @@ import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; + import { useAnnotator, useSelection } from '@annotorious/react'; import { NOT_ANNOTATABLE_CLASS, @@ -7,7 +8,6 @@ import { type TextAnnotator, } from '@recogito/text-annotator'; -import { isMobile } from './isMobile'; import { arrow, autoUpdate, @@ -25,6 +25,8 @@ import { useRole } from '@floating-ui/react'; +import { useAnnotationQuoteIdling } from '../hooks'; +import { isMobile } from './isMobile'; import './TextAnnotationPopup.css'; interface TextAnnotationPopupProps { @@ -60,9 +62,10 @@ export const TextAnnotationPopup = (props: TextAnnotationPopupProps) => { const r = useAnnotator(); const { selected, event } = useSelection(); - const annotation = selected[0]?.annotation; + const isAnnotationQuoteIdling = useAnnotationQuoteIdling(annotation?.id); + const [isOpen, setOpen] = useState(selected?.length > 0); const arrowRef = useRef(null); @@ -93,13 +96,13 @@ export const TextAnnotationPopup = (props: TextAnnotationPopupProps) => { const { getFloatingProps } = useInteractions([dismiss, role]); useEffect(() => { - if (annotation?.id) { + if (annotation?.id && isAnnotationQuoteIdling) { const bounds = r?.state.store.getAnnotationBounds(annotation.id); setOpen(Boolean(bounds)); } else { setOpen(false); } - }, [annotation?.id, r?.state.store]); + }, [annotation?.id, isAnnotationQuoteIdling, r?.state.store]); useEffect(() => { if (!r) return; @@ -179,7 +182,7 @@ export const TextAnnotationPopup = (props: TextAnnotationPopupProps) => { ) : null; -} +}; /** * Prevent text-annotator from handling the irrelevant events 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..4e8b7cca --- /dev/null +++ b/packages/text-annotator-react/src/hooks/index.ts @@ -0,0 +1 @@ +export { useAnnotationQuoteIdling } from './useAnnotationQuoteIdling'; diff --git a/packages/text-annotator-react/src/hooks/useAnnotationQuoteIdling.ts b/packages/text-annotator-react/src/hooks/useAnnotationQuoteIdling.ts new file mode 100644 index 00000000..8fb0887e --- /dev/null +++ b/packages/text-annotator-react/src/hooks/useAnnotationQuoteIdling.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; +import { dequal } from 'dequal/lite'; + +import { Origin, useAnnotationStore } from '@annotorious/react'; +import { Ignore, type StoreChangeEvent } from '@annotorious/core'; +import type { TextAnnotation, TextAnnotationStore, TextAnnotationTarget } from '@recogito/text-annotator'; + +export const useAnnotationQuoteIdling = ( + annotationId: string | undefined, + options: { timeout: number } = { timeout: 500 } +) => { + const store = useAnnotationStore(); + + const [isIdling, setIdling] = useState(true); + + useEffect(() => { + if (!store || !annotationId) return; + + let idlingTimeout: ReturnType; + + const scheduleSetIdling = (event: StoreChangeEvent) => { + const { changes: { updated } } = event; + + const hasChanged = updated?.some((update) => { + const { targetUpdated } = update; + if (targetUpdated) { + const { oldTarget, newTarget } = targetUpdated; + return hasTargetQuoteChanged(oldTarget, newTarget); + } + }); + + if (hasChanged) { + setIdling(false); + + clearTimeout(idlingTimeout); + idlingTimeout = setTimeout(() => setIdling(true), options.timeout); + } + }; + + store.observe(scheduleSetIdling, { + annotations: annotationId, + ignore: Ignore.BODY_ONLY, + origin: Origin.LOCAL + }); + + return () => { + clearTimeout(idlingTimeout); + store.unobserve(scheduleSetIdling); + }; + }, [store, annotationId, options.timeout]); + + return isIdling; +}; + +const hasTargetQuoteChanged = (oldValue: TextAnnotationTarget, newValue: TextAnnotationTarget) => { + const { selector: oldSelector } = oldValue; + const oldQuotes = oldSelector.map(({ quote }) => quote); + + const { selector: newSelector } = newValue; + const newQuotes = newSelector.map(({ quote }) => quote); + + return !dequal(oldQuotes, newQuotes); +}; diff --git a/packages/text-annotator-react/src/index.ts b/packages/text-annotator-react/src/index.ts index f67af5d3..8fb99d45 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 './TextAnnotationPopup'; export * from './TextAnnotatorPlugin'; diff --git a/packages/text-annotator-react/test/App.tsx b/packages/text-annotator-react/test/App.tsx index 3e6db393..3a0ae666 100644 --- a/packages/text-annotator-react/test/App.tsx +++ b/packages/text-annotator-react/test/App.tsx @@ -188,17 +188,18 @@ export const App: FC = () => { to pity him except Neptune, who still persecuted him without ceasing and would not let him get home.

-
- () - } - /> + () + } + /> + + diff --git a/packages/text-annotator/src/SelectionHandler.ts b/packages/text-annotator/src/SelectionHandler.ts index d6451432..c811cf5d 100644 --- a/packages/text-annotator/src/SelectionHandler.ts +++ b/packages/text-annotator/src/SelectionHandler.ts @@ -243,8 +243,10 @@ export const SelectionHandler = ( if (sel?.isCollapsed) return; - // When selecting the initial word, Chrome Android fires `contextmenu` - // before selectionChanged. + /** + * When selecting the initial word, Chrome Android + * fires the `contextmenu` before the `selectionchange` + */ if (!currentTarget || currentTarget.selector.length === 0) { onSelectionChange(evt); }