Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revised Selection Behavior #151

Merged
merged 24 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* or the screen reader users as the popup behavior hint
* Inspired by https://gist.github.com/ffoodd/000b59f431e3e64e4ce1a24d5bb36034
*/
.popup-close-message {
.r6o-popup-sr-only {
border: 0 !important;
clip: rect(1px, 1px, 1px, 1px);
-webkit-clip-path: inset(50%);
Expand All @@ -17,8 +17,8 @@
white-space: nowrap;
}

.popup-close-message:focus,
.popup-close-message:active {
.r6o-popup-sr-only:focus,
.r6o-popup-sr-only:active {
clip: auto;
-webkit-clip-path: none;
clip-path: none;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import React, { PointerEvent, ReactNode, useCallback, useEffect, useState } from 'react';
import { PointerEvent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { useAnnotator, useSelection } from '@annotorious/react';
import type { TextAnnotation, TextAnnotator } from '@recogito/text-annotator';
import { isMobile } from './isMobile';
import {
autoUpdate,
flip,
Expand All @@ -13,13 +16,12 @@ import {
useRole
} from '@floating-ui/react';

import { useAnnotator, useSelection } from '@annotorious/react';
import type { TextAnnotation, TextAnnotator } from '@recogito/text-annotator';

import './TextAnnotatorPopup.css';

interface TextAnnotationPopupProps {

ariaCloseWarning?: string;
rsimon marked this conversation as resolved.
Show resolved Hide resolved

popup(props: TextAnnotationPopupContentProps): ReactNode;

}
Expand All @@ -39,24 +41,18 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => {
const r = useAnnotator<TextAnnotator>();

const { selected, event } = useSelection<TextAnnotation>();

const annotation = selected[0]?.annotation;

const [isOpen, setOpen] = useState(selected?.length > 0);

const handleClose = () => {
r?.cancelSelected();
};

const { refs, floatingStyles, update, context } = useFloating({
placement: 'top',
placement: isMobile() ? 'bottom' : 'top',
open: isOpen,
onOpenChange: (open, _event, reason) => {
setOpen(open);

if (!open) {
if (reason === 'escape-key' || reason === 'focus-out') {
r?.cancelSelected();
}
if (!open && (reason === 'escape-key' || reason === 'focus-out')) {
setOpen(open);
r?.cancelSelected();
}
},
middleware: [
Expand All @@ -69,48 +65,35 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => {
});

const dismiss = useDismiss(context);

const role = useRole(context, { role: 'dialog' });
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) {
setOpen(event.type === 'pointerup' || event.type === 'keydown');
}
}, [selectedKey, event]);
const { getFloatingProps } = useInteractions([dismiss, role]);

useEffect(() => {
// Close the popup if the selection is cleared
if (selected.length === 0 && isOpen) {
setOpen(false);
}
}, [isOpen, selectedKey]);
setOpen(selected.length > 0);
}, [selected.map(a => a.annotation.id).join('-')]);

useEffect(() => {
if (isOpen && annotation) {
// Extra precaution - shouldn't normally happen
if (!annotation.target.selector || annotation.target.selector.length < 1) return;
rsimon marked this conversation as resolved.
Show resolved Hide resolved

const {
target: {
selector: [{ range }]
}
} = annotation;

refs.setPositionReference({
getBoundingClientRect: range.getBoundingClientRect.bind(range),
getClientRects: range.getClientRects.bind(range)
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects()
});
} 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
const getStopEventsPropagationProps = useCallback(
() => ({ onPointerUp: (event: PointerEvent<HTMLDivElement>) => event.stopPropagation() }),
[]
);

useEffect(() => {
const config: MutationObserverInit = { attributes: true, childList: true, subtree: true };

Expand All @@ -125,21 +108,27 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => {
};
}, [update]);

// Prevent text-annotator from handling the irrelevant events triggered from the popup
const getStopEventsPropagationProps = useCallback(
() => ({ onPointerUp: (event: PointerEvent<HTMLDivElement>) => event.stopPropagation() }),
[]
);

// Don't shift focus to the floating element if selected via keyboard or on mobile.
const initialFocus = useMemo(() => {
return (event?.type === 'keyup' || event?.type === 'contextmenu' || isMobile()) ? -1 : 0;
}, [event]);

const onClose = () => r?.cancelSelected();

return isOpen && selected.length > 0 ? (
<FloatingPortal>
<FloatingFocusManager
context={context}
modal={false}
closeOnFocusOut={true}
initialFocus={
/**
* Don't shift focus to the floating element
* when the selection performed with the keyboard
*/
event?.type === 'keydown' ? -1 : 0
}
returnFocus={false}
>
initialFocus={initialFocus}>
<div
className="annotation-popup text-annotation-popup not-annotatable"
ref={refs.setFloating}
Expand All @@ -152,13 +141,12 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => {
event
})}

{/* It lets keyboard/sr users to know that the dialog closes when they focus out of it */}
<button className="popup-close-message" onClick={handleClose}>
This dialog closes when you leave it.
<button className="r6o-popup-sr-only" aria-live="assertive" onClick={onClose}>
{props.ariaCloseWarning || 'Click or leave this dialog to close it.'}
</button>
</div>
</FloatingFocusManager>
</FloatingPortal>
) : null;

};
}
17 changes: 17 additions & 0 deletions packages/text-annotator-react/src/TextAnnotatorPopup/isMobile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// https://stackoverflow.com/questions/21741841/detecting-ios-android-operating-system
export const isMobile = () => {
// @ts-ignore
var userAgent: string = navigator.userAgent || navigator.vendor || window.opera;

if (/android/i.test(userAgent))
return true;

// @ts-ignore
// Note: as of recently, this NO LONGER DETECTS FIREFOX ON iOS!
// This means FF/iOS will behave like on the desktop, and loose
// selection handlebars after the popup opens.
if (/iPad|iPhone/.test(userAgent) && !window.MSStream)
return true;

return false;
}
4 changes: 2 additions & 2 deletions packages/text-annotator-react/test/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { FC, useCallback, useEffect } from 'react';
import { AnnotationBody, Annotorious, useAnnotationStore, useAnnotator, useSelection } from '@annotorious/react';
import { TextAnnotator, TextAnnotatorPopup, type TextAnnotationPopupContentProps } from '../src';
import { AnnotationBody, Annotorious, useAnnotationStore, useAnnotator } from '@annotorious/react';
import { TextAnnotationPopupContentProps, TextAnnotator, TextAnnotatorPopup } from '../src';
import { W3CTextFormat, type TextAnnotation, type TextAnnotator as RecogitoTextAnnotator } from '@recogito/text-annotator';

const TestPopup: FC<TextAnnotationPopupContentProps> = (props) => {
Expand Down
Loading