Browser extension that demonstrates the Web Annotation Discovery mechanism: subscribe to people’s annotation collections/‘feeds’, to see their notes on the web; and create & publish annotations yourself.

web-annotation-discovery-we.../src/content_script/ AnnotationTargetHighlight.tsx
233 lines
7.2 KiB

  1. import { Component, ComponentChild, RenderableProps, h } from 'preact';
  2. import deepEqual from 'deep-equal';
  3. import scrollIntoView from 'scroll-into-view-if-needed';
  4. import classes from './MarginalAnnotations.module.scss';
  5. import cls from 'classnames';
  6. import { asArray, targetsUrl } from 'web-annotation-utils';
  7. import { highlightText } from '@apache-annotator/dom';
  8. import {
  9. MarginalAnnotationCard,
  10. MarginalAnnotationCardProps,
  11. } from './MarginalAnnotationCard';
  12. import { findTargetsInDocument } from '../util/dom-selectors'
  13. import type { IAnnotationWithSource } from '../storage/Annotation';
  14. interface AnnotationTargetHighlightProps extends MarginalAnnotationCardProps {
  15. appContainer: Node;
  16. annotation: IAnnotationWithSource;
  17. inFocus?: boolean;
  18. }
  19. interface AnnotationTargetHighlightState {
  20. highlightHovered: boolean;
  21. highlightClicked: boolean;
  22. anchorPosition?: number;
  23. }
  24. // This component renders the annotation card, but as a side
  25. // effect highlights the annotation’s target in the page. It adds classes and
  26. // event listeners to the wrapped annotation, to light up matching bodies and
  27. // targets on clicking&hovering.
  28. export class AnnotationTargetHighlight extends Component<
  29. AnnotationTargetHighlightProps,
  30. AnnotationTargetHighlightState
  31. > {
  32. state: AnnotationTargetHighlightState = {
  33. highlightHovered: false,
  34. highlightClicked: false,
  35. };
  36. highlightCleanupFunctions: (() => void)[] = [];
  37. highlightMarkElements: HTMLElement[] = [];
  38. constructor() {
  39. super();
  40. this.handleMouseClickOnCard = this.handleMouseClickOnCard.bind(this);
  41. this.handleHighlightMouseEnter = this.handleHighlightMouseEnter.bind(this);
  42. this.handleHighlightMouseLeave = this.handleHighlightMouseLeave.bind(this);
  43. this.handleKeyUp = this.handleKeyUp.bind(this);
  44. }
  45. componentDidMount() {
  46. this.highlightTargets();
  47. // if (this.props.inFocus) {
  48. // this.setState({ highlightClicked: true });
  49. // }
  50. }
  51. componentDidUpdate(
  52. previousProps: Readonly<AnnotationTargetHighlightProps>,
  53. previousState: Readonly<AnnotationTargetHighlightState>,
  54. ) {
  55. if (this.state.highlightClicked !== previousState.highlightClicked) {
  56. this.highlightMarkElements.forEach((markElement) =>
  57. this.state.highlightClicked
  58. ? markElement.classList.add(classes.clicked)
  59. : markElement.classList.remove(classes.clicked),
  60. );
  61. }
  62. if (this.state.highlightHovered !== previousState.highlightHovered) {
  63. this.highlightMarkElements.forEach((markElement) =>
  64. this.state.highlightHovered
  65. ? markElement.classList.add(classes.hovered)
  66. : markElement.classList.remove(classes.hovered),
  67. );
  68. }
  69. if (
  70. !deepEqual(
  71. this.props.annotation.annotation.target,
  72. previousProps.annotation.annotation.target,
  73. )
  74. ) {
  75. this.removeHighlights();
  76. this.highlightTargets();
  77. }
  78. if (this.props.inFocus && !previousProps.inFocus) {
  79. this.setState({ highlightClicked: true });
  80. }
  81. }
  82. componentWillUnmount() {
  83. this.removeHighlights();
  84. }
  85. async highlightTargets() {
  86. const targets = asArray(this.props.annotation.annotation.target);
  87. // Annotations may have multiple targets; ignore those not on this page.
  88. const targetsForThisPage = targets.filter((target) =>
  89. targetsUrl(target, document.URL),
  90. );
  91. // Try find the annotation targets within the document (if the target has a Selector).
  92. const domTargets = await findTargetsInDocument(targetsForThisPage);
  93. // Highlight the found targets.
  94. this.highlightCleanupFunctions = domTargets.flatMap((domTarget) => {
  95. // Ignore any matches inside our own annotations etc.
  96. if (
  97. (domTarget instanceof Range &&
  98. domTarget.intersectsNode(this.props.appContainer)) ||
  99. (domTarget instanceof Node &&
  100. domTarget.compareDocumentPosition(this.props.appContainer) &
  101. Node.DOCUMENT_POSITION_CONTAINS)
  102. ) {
  103. return [];
  104. }
  105. // Highlight the text contained in the Range using <mark> elements.
  106. const highlightCleanupFunction = highlightText(domTarget, 'mark', {
  107. class: classes.highlight,
  108. 'data-annotation-highlight-temp': 'true',
  109. });
  110. // TODO tweak highlightText upstream to not need this silly workaround.
  111. const markElements = [
  112. ...document.querySelectorAll('[data-annotation-highlight-temp]'),
  113. ] as HTMLElement[];
  114. // React to mouse interactions with the highlight.
  115. markElements.forEach((markElement) => {
  116. markElement.removeAttribute('data-annotation-highlight-temp');
  117. markElement.addEventListener('click', () => this.handleMouseClick());
  118. markElement.addEventListener('mouseenter', () =>
  119. this.handleHighlightMouseEnter(),
  120. );
  121. markElement.addEventListener('mouseleave', () =>
  122. this.handleHighlightMouseLeave(),
  123. );
  124. });
  125. this.highlightMarkElements.push(...markElements);
  126. return [highlightCleanupFunction];
  127. });
  128. if (this.highlightMarkElements.length > 0) {
  129. const top = this.highlightMarkElements[0].getBoundingClientRect().top + window.scrollY;
  130. this.setState({
  131. anchorPosition: top,
  132. });
  133. }
  134. }
  135. removeHighlights() {
  136. this.highlightCleanupFunctions.forEach((removeHighlight) =>
  137. removeHighlight(),
  138. );
  139. this.highlightCleanupFunctions = [];
  140. this.highlightMarkElements = [];
  141. this.setState({
  142. highlightClicked: false,
  143. highlightHovered: false,
  144. });
  145. }
  146. handleMouseClick() {
  147. this.setState((currentState) => ({
  148. highlightClicked: !currentState.highlightClicked,
  149. }));
  150. }
  151. handleMouseClickOnCard() {
  152. if (!this.state.highlightClicked && this.highlightMarkElements.length > 0) {
  153. const firstMarkElement = this.highlightMarkElements[0];
  154. if (firstMarkElement.getBoundingClientRect().top)
  155. scrollIntoView(firstMarkElement, { behavior: 'smooth', block: 'center', scrollMode: 'if-needed' });
  156. }
  157. this.setState({
  158. highlightClicked: false,
  159. });
  160. }
  161. handleHighlightMouseEnter() {
  162. this.setState({ highlightHovered: true });
  163. }
  164. handleHighlightMouseLeave() {
  165. this.setState({ highlightHovered: false });
  166. }
  167. handleKeyUp(event: KeyboardEvent) {
  168. if (event.key === 'Escape') {
  169. this.setState({ highlightClicked: false });
  170. // TODO move focus out of annotation to hide it.
  171. }
  172. }
  173. render(
  174. {
  175. annotation,
  176. inFocus,
  177. ...otherProps
  178. }: RenderableProps<AnnotationTargetHighlightProps>,
  179. {
  180. highlightHovered,
  181. highlightClicked,
  182. anchorPosition,
  183. }: Readonly<AnnotationTargetHighlightState>,
  184. ): ComponentChild {
  185. const extraClasses = cls({
  186. [classes.highlightClicked]: highlightClicked,
  187. [classes.highlightHovered]: highlightHovered,
  188. });
  189. // Pass the extra class to the wrapped annotation.
  190. return (
  191. <MarginalAnnotationCard
  192. {...otherProps}
  193. annotation={annotation}
  194. onclick={this.handleMouseClickOnCard}
  195. onmouseenter={this.handleHighlightMouseEnter}
  196. onmouseleave={this.handleHighlightMouseLeave}
  197. onkeyup={this.handleKeyUp}
  198. anchorPosition={anchorPosition}
  199. extraClasses={extraClasses}
  200. inFocus={inFocus}
  201. />
  202. );
  203. }
  204. }