Commit 2874ac0a authored by Eduardo Sanz García's avatar Eduardo Sanz García Committed by Eduardo

Stop propagation of click and touchstart events

On touch devices every touch event should eventually result on a click
event, as explained
[here](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events/Supporting_both_TouchEvent_and_MouseEvent#event_order).
However, this is not true for mobile Safari versions < 13 (2019).

Because we still rely on listening to touchstart events, we decided to
sandbox the shadow DOMs, by stopping the propagation of click and
touchstart at that level. This prevents the bubbling of these events
outside the shadow DOM and better isolates the pieces of the application
that should not interact with the host page.
parent 79f92349
...@@ -64,13 +64,6 @@ export default function AdderToolbar({ ...@@ -64,13 +64,6 @@ export default function AdderToolbar({
onCommand, onCommand,
annotationCount = 0, annotationCount = 0,
}) { }) {
const handleCommand = (event, command) => {
event.preventDefault();
event.stopPropagation();
onCommand(command);
};
// Since the selection toolbar is only shown when there is a selection // Since the selection toolbar is only shown when there is a selection
// of static text, we can use a plain key without any modifier as // of static text, we can use a plain key without any modifier as
// the shortcut. This avoids conflicts with browser/OS shortcuts. // the shortcut. This avoids conflicts with browser/OS shortcuts.
...@@ -92,13 +85,13 @@ export default function AdderToolbar({ ...@@ -92,13 +85,13 @@ export default function AdderToolbar({
<div className="AdderToolbar__actions"> <div className="AdderToolbar__actions">
<ToolbarButton <ToolbarButton
icon="annotate" icon="annotate"
onClick={e => handleCommand(e, 'annotate')} onClick={() => onCommand('annotate')}
label="Annotate" label="Annotate"
shortcut={annotateShortcut} shortcut={annotateShortcut}
/> />
<ToolbarButton <ToolbarButton
icon="highlight" icon="highlight"
onClick={e => handleCommand(e, 'highlight')} onClick={() => onCommand('highlight')}
label="Highlight" label="Highlight"
shortcut={highlightShortcut} shortcut={highlightShortcut}
/> />
...@@ -106,7 +99,7 @@ export default function AdderToolbar({ ...@@ -106,7 +99,7 @@ export default function AdderToolbar({
{annotationCount > 0 && ( {annotationCount > 0 && (
<ToolbarButton <ToolbarButton
badgeCount={annotationCount} badgeCount={annotationCount}
onClick={e => handleCommand(e, 'show')} onClick={() => onCommand('show')}
label="Show" label="Show"
shortcut={showShortcut} shortcut={showShortcut}
/> />
......
...@@ -22,7 +22,6 @@ function BucketButton({ bucket, onSelectAnnotations }) { ...@@ -22,7 +22,6 @@ function BucketButton({ bucket, onSelectAnnotations }) {
const buttonTitle = `Select nearby annotations (${bucket.anchors.length})`; const buttonTitle = `Select nearby annotations (${bucket.anchors.length})`;
function selectAnnotations(event) { function selectAnnotations(event) {
event.stopPropagation();
onSelectAnnotations(annotations, event.metaKey || event.ctrlKey); onSelectAnnotations(annotations, event.metaKey || event.ctrlKey);
} }
...@@ -58,17 +57,17 @@ function BucketButton({ bucket, onSelectAnnotations }) { ...@@ -58,17 +57,17 @@ function BucketButton({ bucket, onSelectAnnotations }) {
function NavigationBucketButton({ bucket, direction }) { function NavigationBucketButton({ bucket, direction }) {
const buttonTitle = `Go ${direction} to next annotations (${bucket.anchors.length})`; const buttonTitle = `Go ${direction} to next annotations (${bucket.anchors.length})`;
function scrollToClosest(event) { function scrollToClosest() {
event.stopPropagation();
const closest = findClosestOffscreenAnchor(bucket.anchors, direction); const closest = findClosestOffscreenAnchor(bucket.anchors, direction);
if (closest?.highlights?.length) { if (closest?.highlights?.length) {
scrollIntoView(closest.highlights[0]); scrollIntoView(closest.highlights[0]);
} }
} }
return ( return (
<button <button
className={classnames('Buckets__button', `Buckets__button--${direction}`)} className={classnames('Buckets__button', `Buckets__button--${direction}`)}
onClick={event => scrollToClosest(event)} onClick={scrollToClosest}
title={buttonTitle} title={buttonTitle}
aria-label={buttonTitle} aria-label={buttonTitle}
> >
......
...@@ -19,20 +19,13 @@ function ToolbarButton({ ...@@ -19,20 +19,13 @@ function ToolbarButton({
onClick, onClick,
selected = false, selected = false,
}) { }) {
const handleClick = event => {
// Stop event from propagating up to the document and being treated as a
// click on document content, causing the sidebar to close.
event.stopPropagation();
onClick();
};
return ( return (
<button <button
className={className} className={className}
aria-label={label} aria-label={label}
aria-expanded={expanded} aria-expanded={expanded}
aria-pressed={selected} aria-pressed={selected}
onClick={handleClick} onClick={onClick}
ref={buttonRef} ref={buttonRef}
title={label} title={label}
> >
......
...@@ -212,8 +212,8 @@ export default class Guest extends Delegator { ...@@ -212,8 +212,8 @@ export default class Guest extends Delegator {
} }
}); });
// Allow taps on the document to hide the sidebar as well as clicks, because // Allow taps on the document to hide the sidebar as well as clicks.
// on touch-input devices, not all elements will generate a "click" event. // On iOS < 13 (2019), elements like h2 or div don't emit 'click' events.
addListener('touchstart', event => { addListener('touchstart', event => {
if (!annotationsAt(event.target).length) { if (!annotationsAt(event.target).length) {
maybeCloseSidebar(event); maybeCloseSidebar(event);
......
...@@ -116,22 +116,19 @@ export default class Notebook extends Delegator { ...@@ -116,22 +116,19 @@ export default class Notebook extends Delegator {
this.container.className = 'notebook-outer'; this.container.className = 'notebook-outer';
shadowRoot.appendChild(this.container); shadowRoot.appendChild(this.container);
const close = event => { const onClose = () => {
// Guest 'component' captures all click or touchstart events in the host page and opens the sidebar.
// We stop the propagation of the event to prevent the sidebar to be opened.
event.stopPropagation();
this.close(); this.close();
this.publish('closeNotebook'); this.publish('closeNotebook');
}; };
render( render(
<div className="Notebook__controller-bar" onTouchStart={close}> <div className="Notebook__controller-bar">
<Button <Button
icon="cancel" icon="cancel"
className="Notebook__close-button" className="Notebook__close-button"
buttonText="Close" buttonText="Close"
title="Close the Notebook" title="Close the Notebook"
onClick={close} onClick={onClose}
/> />
</div>, </div>,
this.container this.container
......
...@@ -30,6 +30,7 @@ function loadStyles(shadowRoot) { ...@@ -30,6 +30,7 @@ function loadStyles(shadowRoot) {
*/ */
export function createShadowRoot(container) { export function createShadowRoot(container) {
if (!container.attachShadow) { if (!container.attachShadow) {
stopEventPropagation(container);
return container; return container;
} }
...@@ -43,5 +44,24 @@ export function createShadowRoot(container) { ...@@ -43,5 +44,24 @@ export function createShadowRoot(container) {
applyFocusVisible(shadowRoot); applyFocusVisible(shadowRoot);
} }
stopEventPropagation(shadowRoot);
return shadowRoot; return shadowRoot;
} }
/**
* Stop bubbling up of 'click' and 'touchstart' events.
*
* This makes the host page a little bit less aware of the annotator activity.
* It is still possible for the host page to manipulate the events on the capturing
* face.
*
* Another benefit is that click and touchstart typically causes the sidebar to close.
* By preventing the bubble up of these events, we don't have to individually stop
* the propagation.
*
* @param {HTMLElement|ShadowRoot} element
*/
function stopEventPropagation(element) {
element.addEventListener('click', event => event.stopPropagation());
element.addEventListener('touchstart', event => event.stopPropagation());
}
...@@ -14,6 +14,7 @@ describe('annotator/util/shadow-root', () => { ...@@ -14,6 +14,7 @@ describe('annotator/util/shadow-root', () => {
container.remove(); container.remove();
window.applyFocusVisiblePolyfill = applyFocusVisiblePolyfill; window.applyFocusVisiblePolyfill = applyFocusVisiblePolyfill;
}); });
describe('createShadowRoot', () => { describe('createShadowRoot', () => {
it('attaches a shadow root to the container', () => { it('attaches a shadow root to the container', () => {
const shadowRoot = createShadowRoot(container); const shadowRoot = createShadowRoot(container);
...@@ -56,5 +57,37 @@ describe('annotator/util/shadow-root', () => { ...@@ -56,5 +57,37 @@ describe('annotator/util/shadow-root', () => {
assert.isNull(linkEl); assert.isNull(linkEl);
link.setAttribute('rel', 'stylesheet'); link.setAttribute('rel', 'stylesheet');
}); });
it('stops propagation of click events', () => {
const onClick = sinon.stub();
container.addEventListener('click', onClick);
const shadowRoot = createShadowRoot(container);
const innerElement = document.createElement('div');
shadowRoot.appendChild(innerElement);
innerElement.dispatchEvent(
// `composed` property is necessary to bubble up the event out of the shadow DOM.
// browser generated events, have this property set to true.
new Event('click', { bubbles: true, composed: true })
);
assert.notCalled(onClick);
});
it('stops propagation of touchstart events', () => {
const onTouch = sinon.stub();
container.addEventListener('touchstart', onTouch);
const shadowRoot = createShadowRoot(container);
const innerElement = document.createElement('div');
shadowRoot.appendChild(innerElement);
// `composed` property is necessary to bubble up the event out of the shadow DOM.
// browser generated events, have this property set to true.
innerElement.dispatchEvent(
new Event('touchstart', { bubbles: true, composed: true })
);
assert.notCalled(onTouch);
});
}); });
}); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment