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);
}