import { Component, ComponentChild, RenderableProps, h } from 'preact'; import deepEqual from 'deep-equal'; import scrollIntoView from 'scroll-into-view-if-needed'; import classes from './MarginalAnnotations.module.scss'; import cls from 'classnames'; import { asArray, targetsUrl } from 'web-annotation-utils'; import { highlightText } from '@apache-annotator/dom'; import { MarginalAnnotationCard, MarginalAnnotationCardProps, } from './MarginalAnnotationCard'; import { findTargetsInDocument } from '../util/dom-selectors' import type { IAnnotationWithSource } from '../storage/Annotation'; interface AnnotationTargetHighlightProps extends MarginalAnnotationCardProps { appContainer: Node; annotation: IAnnotationWithSource; inFocus?: boolean; } interface AnnotationTargetHighlightState { highlightHovered: boolean; highlightClicked: boolean; anchorPosition?: number; } // This component renders the annotation card, but as a side // effect highlights the annotation’s target in the page. It adds classes and // event listeners to the wrapped annotation, to light up matching bodies and // targets on clicking&hovering. export class AnnotationTargetHighlight extends Component< AnnotationTargetHighlightProps, AnnotationTargetHighlightState > { state: AnnotationTargetHighlightState = { highlightHovered: false, highlightClicked: false, }; highlightCleanupFunctions: (() => void)[] = []; highlightMarkElements: HTMLElement[] = []; constructor() { super(); this.handleMouseClickOnCard = this.handleMouseClickOnCard.bind(this); this.handleHighlightMouseEnter = this.handleHighlightMouseEnter.bind(this); this.handleHighlightMouseLeave = this.handleHighlightMouseLeave.bind(this); this.handleKeyUp = this.handleKeyUp.bind(this); } componentDidMount() { this.highlightTargets(); // if (this.props.inFocus) { // this.setState({ highlightClicked: true }); // } } componentDidUpdate( previousProps: Readonly, previousState: Readonly, ) { if (this.state.highlightClicked !== previousState.highlightClicked) { this.highlightMarkElements.forEach((markElement) => this.state.highlightClicked ? markElement.classList.add(classes.clicked) : markElement.classList.remove(classes.clicked), ); } if (this.state.highlightHovered !== previousState.highlightHovered) { this.highlightMarkElements.forEach((markElement) => this.state.highlightHovered ? markElement.classList.add(classes.hovered) : markElement.classList.remove(classes.hovered), ); } if ( !deepEqual( this.props.annotation.annotation.target, previousProps.annotation.annotation.target, ) ) { this.removeHighlights(); this.highlightTargets(); } if (this.props.inFocus && !previousProps.inFocus) { this.setState({ highlightClicked: true }); } } componentWillUnmount() { this.removeHighlights(); } async highlightTargets() { const targets = asArray(this.props.annotation.annotation.target); // Annotations may have multiple targets; ignore those not on this page. const targetsForThisPage = targets.filter((target) => targetsUrl(target, document.URL), ); // Try find the annotation targets within the document (if the target has a Selector). const domTargets = await findTargetsInDocument(targetsForThisPage); // Highlight the found targets. this.highlightCleanupFunctions = domTargets.flatMap((domTarget) => { // Ignore any matches inside our own annotations etc. if ( (domTarget instanceof Range && domTarget.intersectsNode(this.props.appContainer)) || (domTarget instanceof Node && domTarget.compareDocumentPosition(this.props.appContainer) & Node.DOCUMENT_POSITION_CONTAINS) ) { return []; } // Highlight the text contained in the Range using elements. const highlightCleanupFunction = highlightText(domTarget, 'mark', { class: classes.highlight, 'data-annotation-highlight-temp': 'true', }); // TODO tweak highlightText upstream to not need this silly workaround. const markElements = [ ...document.querySelectorAll('[data-annotation-highlight-temp]'), ] as HTMLElement[]; // React to mouse interactions with the highlight. markElements.forEach((markElement) => { markElement.removeAttribute('data-annotation-highlight-temp'); markElement.addEventListener('click', () => this.handleMouseClick()); markElement.addEventListener('mouseenter', () => this.handleHighlightMouseEnter(), ); markElement.addEventListener('mouseleave', () => this.handleHighlightMouseLeave(), ); }); this.highlightMarkElements.push(...markElements); return [highlightCleanupFunction]; }); if (this.highlightMarkElements.length > 0) { const top = this.highlightMarkElements[0].getBoundingClientRect().top + window.scrollY; this.setState({ anchorPosition: top, }); } } removeHighlights() { this.highlightCleanupFunctions.forEach((removeHighlight) => removeHighlight(), ); this.highlightCleanupFunctions = []; this.highlightMarkElements = []; this.setState({ highlightClicked: false, highlightHovered: false, }); } handleMouseClick() { this.setState((currentState) => ({ highlightClicked: !currentState.highlightClicked, })); } handleMouseClickOnCard() { if (!this.state.highlightClicked && this.highlightMarkElements.length > 0) { const firstMarkElement = this.highlightMarkElements[0]; if (firstMarkElement.getBoundingClientRect().top) scrollIntoView(firstMarkElement, { behavior: 'smooth', block: 'center', scrollMode: 'if-needed' }); } this.setState({ highlightClicked: false, }); } handleHighlightMouseEnter() { this.setState({ highlightHovered: true }); } handleHighlightMouseLeave() { this.setState({ highlightHovered: false }); } handleKeyUp(event: KeyboardEvent) { if (event.key === 'Escape') { this.setState({ highlightClicked: false }); // TODO move focus out of annotation to hide it. } } render( { annotation, inFocus, ...otherProps }: RenderableProps, { highlightHovered, highlightClicked, anchorPosition, }: Readonly, ): ComponentChild { const extraClasses = cls({ [classes.highlightClicked]: highlightClicked, [classes.highlightHovered]: highlightHovered, }); // Pass the extra class to the wrapped annotation. return ( ); } }