diff --git a/src/components/MarkersDisplay/Annotations/AnnotationLayerSelect.js b/src/components/MarkersDisplay/Annotations/AnnotationLayerSelect.js index 013339ed..ea1fbdd4 100644 --- a/src/components/MarkersDisplay/Annotations/AnnotationLayerSelect.js +++ b/src/components/MarkersDisplay/Annotations/AnnotationLayerSelect.js @@ -1,25 +1,39 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { parseExternalAnnotationPage, parseExternalAnnotationResource } from '@Services/annotations-parser'; -const AnnotationLayerSelect = ({ annotationLayers = [], duration = 0, setDisplayedAnnotationLayers }) => { +const AnnotationLayerSelect = ({ + canvasAnnotationLayers = [], + duration = 0, + setDisplayedAnnotationLayers, + setAutoScrollEnabled, + autoScrollEnabled, +}) => { const [selectedAnnotationLayers, setSelectedAnnotationLayers] = useState([]); const [isOpen, setIsOpen] = useState(false); const [selectedAll, setSelectedAll] = useState(false); useEffect(() => { - if (annotationLayers?.length > 0) { + // Reset state when Canvas changes + setSelectedAnnotationLayers([]); + setDisplayedAnnotationLayers([]); + setSelectedAll(false); + setIsOpen(false); + + if (canvasAnnotationLayers?.length > 0) { // Sort annotation sets alphabetically - annotationLayers.sort((a, b) => a.label.localeCompare(b.label)); + canvasAnnotationLayers.sort((a, b) => a.label.localeCompare(b.label)); // Select the first annotation set on page load - findOrFetchandParseLinkedAnnotations(annotationLayers[0]); + findOrFetchandParseLinkedAnnotations(canvasAnnotationLayers[0]); } - }, [annotationLayers]); + }, [canvasAnnotationLayers]); - const isSelected = (layer) => selectedAnnotationLayers.includes(layer.label); + const isSelected = useCallback((layer) => { + return selectedAnnotationLayers.includes(layer.label); + }, [selectedAnnotationLayers]); const toggleDropdown = () => setIsOpen((prev) => !prev); /** @@ -68,7 +82,7 @@ const AnnotationLayerSelect = ({ annotationLayers = [], duration = 0, setDisplay setSelectedAll(selectAllUpdated); if (selectAllUpdated) { await Promise.all( - annotationLayers.map((annotationLayer) => { + canvasAnnotationLayers.map((annotationLayer) => { findOrFetchandParseLinkedAnnotations(annotationLayer); }) ); @@ -104,52 +118,72 @@ const AnnotationLayerSelect = ({ annotationLayers = [], duration = 0, setDisplay setDisplayedAnnotationLayers((prev) => [...prev, annotationLayer]); }; - return ( -
-
- {selectedAnnotationLayers.length > 0 - ? `${selectedAnnotationLayers.length} of ${annotationLayers.length} layers selected` - : "Select Annotation layer(s)"} - + if (canvasAnnotationLayers?.length > 0) { + return ( +
+
+ {selectedAnnotationLayers.length > 0 + ? `${selectedAnnotationLayers.length} of ${canvasAnnotationLayers.length} layers selected` + : "Select Annotation layer(s)"} + +
+ {isOpen && ( +
    + { + // Only show select all option when there's more than one annotation layer + canvasAnnotationLayers?.length > 1 && +
  • + +
  • + } + {canvasAnnotationLayers.map((annotationLayer, index) => ( +
  • + +
  • + ))} +
+ )} +
+ { setAutoScrollEnabled(!autoScrollEnabled); }} + /> + +
- {isOpen && ( - - )} -
- ); + ); + } else { + return null; + }; }; AnnotationLayerSelect.propTypes = { - annotationLayers: PropTypes.array.isRequired, + canvasAnnotationLayers: PropTypes.array.isRequired, duration: PropTypes.number.isRequired, - setDisplayedAnnotationLayers: PropTypes.func.isRequired + setDisplayedAnnotationLayers: PropTypes.func.isRequired, + setAutoScrollEnabled: PropTypes.func.isRequired, + autoScrollEnabled: PropTypes.bool.isRequired, }; export default AnnotationLayerSelect; diff --git a/src/components/MarkersDisplay/Annotations/AnnotationLayerSelect.test.js b/src/components/MarkersDisplay/Annotations/AnnotationLayerSelect.test.js new file mode 100644 index 00000000..aa2df515 --- /dev/null +++ b/src/components/MarkersDisplay/Annotations/AnnotationLayerSelect.test.js @@ -0,0 +1,484 @@ +import React from 'react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import AnnotationLayerSelect from './AnnotationLayerSelect'; +import * as annotationParser from '@Services/annotations-parser'; + +const annotationLayers = [ + { + label: 'Unknown', + format: 'application/json', + url: 'http://example.com/manifestannotation-page/unknown.json', + }, + { + label: 'Songs', + items: [{ + id: 'songs-annotation-0', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing', 'tagging'], + time: { start: 7, end: undefined }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['tagging'], value: 'Songs' }] + }, + { + id: 'songs-annotation-1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing', 'tagging'], + time: { start: 25.32, end: 27.65 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'The Yale Glee Club singing "Mother of Men"' }, + { format: 'text/plain', purpose: ['tagging'], value: 'Songs' }] + }, + { + id: 'songs-annotation-2', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing', 'tagging'], + time: { start: 29.54, end: 45.32 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Subjects: Singing' }, + { format: 'text/plain', purpose: ['tagging'], value: 'Songs' }] + }], + }, + { + label: 'Texts', + format: 'application/json', + url: 'http://example.com/manifestannotation-page/texts.json', + items: [], + } +]; + +const linkedAnnotationLayers = [ + { + canvasId: 'http://example.com/manifestcanvas/1', + format: 'text/vtt', + id: 'http://example.com/manifestcanvas/1/annotation-page/1/annotation/1', + label: 'Captions in English.vtt', + linkedResource: true, + motivation: ['supplementing'], + url: 'http://example.com/manifestfiles/captions-in-english.vtt', + items: [ + { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing'], + time: { start: 7, end: undefined }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }] + }, + { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing'], + time: { start: 25.32, end: 27.65 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'The Yale Glee Club singing "Mother of Men"' }] + }, + { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing'], + time: { start: 29.54, end: 45.32 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Subjects: Singing' }] + } + ], + }, + { + canvasId: 'http://example.com/manifestcanvas/1', + format: 'text/srt', + id: 'http://example.com/manifestcanvas/1/annotation-page/1/annotation/2', + label: 'Subtitle in English.srt', + linkedResource: true, + motivation: ['supplementing'], + url: 'http://example.com/manifestfiles/subtitles-in-english.srt', + }, +]; + +const annotationPageResponse = { + "@context": "http://iiif.io/api/presentation/3/context.json", + "id": "http://example.com/manifestannotations/unknown.json", + "type": "AnnotationPage", "label": "Unknown", + "items": [ + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "unknown-annotation-1.json", + "type": "Annotation", + "motivation": ["supplementing", "commenting"], + "body": [ + { "type": "TextualBody", "value": "Savannah, GA", "format": "text/plain", "purpose": "commenting" }, + { "type": "TextualBody", "value": "Unknown", "format": "text/plain", "purpose": "tagging" } + ], + "target": { + "source": { + "id": "http://example.com/manifest/canvas-1/canvas", + "type": "Canvas", + }, + "selector": { "type": "PointSelector", "t": "2766.438533" } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "unknown-annotation-2.json", + "type": "Annotation", + "motivation": ["supplementing", "commenting"], + "body": [ + { "type": "TextualBody", "value": "A play that we used to play when we were children in Savannah.", "format": "text/plain", "purpose": "commenting" }, + { "type": "TextualBody", "value": "Unknown", "format": "text/plain", "purpose": "tagging" } + ], + "target": { + "source": { + "id": "http://example.com/manifest/canvas-1/canvas", + "type": "Canvas", + }, + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "t=2771.900826,2775.619835" + } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "unknown-annotation-3.json", + "type": "Annotation", + "motivation": ["supplementing", "commenting"], + "body": [ + { "type": "TextualBody", "value": "A ring play, just a ring play, a children's ring play", "format": "text/plain", "purpose": "commenting" }, + { "type": "TextualBody", "value": "Unknown", "format": "text/plain", "purpose": "tagging" } + ], + "target": { + "source": { + "id": "http://example.com/manifest/canvas-1/canvas", + "type": "Canvas", + }, + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "t=2779.493802,2782.438017" + } + } + } + ] +}; + +describe('AnnotationLayerSelect component', () => { + const setAutoScrollEnabledMock = jest.fn(); + const setDisplayedAnnotationLayersMock = jest.fn(); + + test('displays nothing when there are no annotation layers', () => { + render(); + + expect(screen.queryByTestId('annotation-multi-select')).not.toBeInTheDocument(); + }); + + describe('displays', () => { + beforeEach(() => { + render(); + }); + + test('a multi-select box and a checkbox for auto-scroll on initial load', () => { + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + const multiSelect = screen.getByTestId('annotation-multi-select'); + // Displays only multi select box and auto-scroll checkbox on initial load + expect(multiSelect.childNodes[0]).toHaveClass('ramp--annotations__multi-select-header'); + expect(multiSelect.childNodes[0]).toHaveTextContent('1 of 3 layers selected▼'); + expect(multiSelect.childNodes[1]).toHaveClass('ramp--annotations__scroll'); + expect(multiSelect.childNodes[1]).toHaveTextContent('Auto-scroll with media'); + }); + + test('a list of annotation layers on click', () => { + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + const multiSelect = screen.getByTestId('annotation-multi-select'); + const multiSelectHeader = multiSelect.childNodes[0]; + expect(multiSelectHeader).toHaveTextContent('1 of 3 layers selected▼'); + + fireEvent.click(multiSelectHeader); + + expect(multiSelect.childNodes[1].tagName).toEqual('UL'); + expect(multiSelect.childNodes[1].childNodes.length).toEqual(4); + }); + + test('\'Show all Annotation layers\' option on top', () => { + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + const multiSelect = screen.getByTestId('annotation-multi-select'); + const multiSelectHeader = multiSelect.childNodes[0]; + expect(multiSelectHeader).toHaveTextContent('1 of 3 layers selected▼'); + + fireEvent.click(multiSelectHeader); + + expect(multiSelect.childNodes[1].childNodes.length).toEqual(4); + expect(multiSelect.childNodes[1].childNodes[0]).toHaveTextContent('Show all Annotation layers'); + }); + + test('list of annotation layers in alphabetical order', () => { + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + const multiSelect = screen.getByTestId('annotation-multi-select'); + const multiSelectHeader = multiSelect.childNodes[0]; + expect(multiSelectHeader).toHaveTextContent('1 of 3 layers selected▼'); + + fireEvent.click(multiSelectHeader); + + const annotationList = multiSelect.childNodes[1]; + expect(annotationList.childNodes.length).toEqual(4); + expect(annotationList.childNodes[1]).toHaveTextContent('Songs'); + expect(annotationList.childNodes[2]).toHaveTextContent('Texts'); + }); + + test('the first annotation layer as selected by default', () => { + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + const multiSelect = screen.getByTestId('annotation-multi-select'); + const multiSelectHeader = multiSelect.childNodes[0]; + expect(multiSelectHeader).toHaveTextContent('1 of 3 layers selected▼'); + + fireEvent.click(multiSelectHeader); + + const annotationList = multiSelect.childNodes[1]; + expect(annotationList.childNodes.length).toEqual(4); + expect(annotationList.childNodes[1]).toHaveTextContent('Songs'); + expect(within(annotationList.childNodes[1]).getByRole('checkbox')).toBeChecked(); + + expect(annotationList.childNodes[2]).toHaveTextContent('Texts'); + expect(within(annotationList.childNodes[2]).getByRole('checkbox')).not.toBeChecked(); + }); + }); + + test('displays annotations options for linked resources', () => { + render(); + + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + const multiSelect = screen.getByTestId('annotation-multi-select'); + const multiSelectHeader = multiSelect.childNodes[0]; + expect(multiSelectHeader).toHaveTextContent('1 of 2 layers selected▼'); + + fireEvent.click(multiSelectHeader); + + expect(multiSelect.childNodes[1].tagName).toEqual('UL'); + expect(multiSelect.childNodes[1].childNodes.length).toEqual(3); + }); + + describe('\'Show all Annotation layers\' option', () => { + test('is displayed when more than 1 annotation layer is present', () => { + render(); + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + const multiSelect = screen.getByTestId('annotation-multi-select'); + const multiSelectHeader = multiSelect.childNodes[0]; + expect(multiSelectHeader).toHaveTextContent('1 of 3 layers selected▼'); + + fireEvent.click(multiSelectHeader); + + expect(multiSelect.childNodes[1].tagName).toEqual('UL'); + expect(multiSelect.childNodes[1].childNodes.length).toEqual(4); + expect(screen.getByText('Show all Annotation layers')).toBeInTheDocument(); + expect(screen.getByText('Songs')).toBeInTheDocument(); + }); + + test('is not displayed when there is one annotation layer', () => { + render(); + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + const multiSelect = screen.getByTestId('annotation-multi-select'); + const multiSelectHeader = multiSelect.childNodes[0]; + expect(multiSelectHeader).toHaveTextContent('1 of 1 layers selected▼'); + + fireEvent.click(multiSelectHeader); + + expect(multiSelect.childNodes[1].tagName).toEqual('UL'); + expect(multiSelect.childNodes[1].childNodes.length).toEqual(1); + expect(screen.queryByText('Show all Annotation layers')).not.toBeInTheDocument(); + expect(screen.getByText('Songs')).toBeInTheDocument(); + }); + }); + + describe('updates the annotation selection when', () => { + test('an annotation layer representing an external AnnotationPage is selected', async () => { + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + status: 201, + ok: true, + json: jest.fn(() => { return annotationPageResponse; }) + }); + + render(); + + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + const multiSelect = screen.getByTestId('annotation-multi-select'); + const multiSelectHeader = multiSelect.childNodes[0]; + + // Open the annotation layers list dropdown + fireEvent.click(multiSelectHeader); + + const annotationLlist = multiSelect.childNodes[1]; + // Check the annotation layer with label 'Texts' is not selected + expect(multiSelectHeader).toHaveTextContent('1 of 3 layers selected▼'); + expect(annotationLlist.childNodes[3]).toHaveTextContent('Unknown'); + expect(within(annotationLlist.childNodes[3]).getByRole('checkbox')).not.toBeChecked(); + + const checkBox = screen.queryAllByRole('checkbox')[3]; + + // Wrap in act() to ensure all state updates are processed before assertions are run. + // Select the 'Unknown' annotation layer from list + await act(() => { + fireEvent.click(checkBox); + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(multiSelectHeader).toHaveTextContent('2 of 3 layers selected▼'); + }); + + test('an annotation layer representing a linked WebVTT resource is selected', async () => { + const mockResponse = + 'WEBVTT\r\n\r\n1\r\n00:00:01.200 --> 00:00:21.000\n[music]\n\r\n2\r\n00:00:22.200 --> 00:00:26.600\nJust before lunch one day, a puppet show \nwas put on at school.\n\r\n3\r\n00:00:26.700 --> 00:00:31.500\nIt was called "Mister Bungle Goes to Lunch".\n\r\n4\r\n00:00:31.600 --> 00:00:34.500\nIt was fun to watch.\n\r\n5\r\n00:00:36.100 --> 00:00:41.300\nIn the puppet show, Mr. Bungle came to the \nboys\' room on his way to lunch.\n'; + const fetchWebVTT = jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + status: 200, + ok: true, + headers: { get: jest.fn(() => 'text/vtt') }, + text: jest.fn(() => mockResponse), + }); + const parseExternalAnnotationResourceMock = jest + .spyOn(annotationParser, 'parseExternalAnnotationResource'); + + render(); + + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + const multiSelect = screen.getByTestId('annotation-multi-select'); + const multiSelectHeader = multiSelect.childNodes[0]; + + // Open the annotation layers list dropdown + fireEvent.click(multiSelectHeader); + + const annotationLlist = multiSelect.childNodes[1]; + + // Check the annotation layer with label 'Subtitle in English.srt' is not selected + expect(multiSelectHeader).toHaveTextContent('1 of 2 layers selected▼'); + expect(annotationLlist.childNodes[2]).toHaveTextContent('Subtitle in English.srt'); + expect(within(annotationLlist.childNodes[2]).getByRole('checkbox')).not.toBeChecked(); + + const checkBox = screen.queryAllByRole('checkbox')[2]; + + // Wrap in act() to ensure all state updates are processed before assertions are run. + // Select the 'Subtitle in English.srt' annotation layer from list + await act(() => { + fireEvent.click(checkBox); + }); + + expect(parseExternalAnnotationResourceMock).toHaveBeenCalledTimes(1); + expect(fetchWebVTT).toHaveBeenCalledTimes(1); + expect(multiSelectHeader).toHaveTextContent('2 of 2 layers selected▼'); + }); + + test('\'Show all Annotation layers\' option is selected', async () => { + jest.spyOn(annotationParser, 'parseExternalAnnotationPage') + .mockResolvedValue([{}]); + + render(); + + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + const multiSelect = screen.getByTestId('annotation-multi-select'); + const multiSelectHeader = multiSelect.childNodes[0]; + + // Open the annotation layers list dropdown + fireEvent.click(multiSelectHeader); + + const annotationLlist = multiSelect.childNodes[1]; + // Check 'Show all Annotation layers' option is not selected + expect(multiSelectHeader).toHaveTextContent('1 of 3 layers selected▼'); + expect(annotationLlist.childNodes[0]).toHaveTextContent('Show all Annotation layers'); + expect(within(annotationLlist.childNodes[0]).getByRole('checkbox')).not.toBeChecked(); + + const checkBox = screen.queryAllByRole('checkbox')[0]; + + // Wrap in act() to ensure all state updates are processed before assertions are run. + // Select the 'Show all Annotation layers' annotation layer from list + await act(() => { + fireEvent.click(checkBox); + }); + + // Dropdown list for annotation layers collapses + expect(screen.queryByText('Show all Annotation layers')).not.toBeInTheDocument(); + expect(screen.queryByText('Songs')).not.toBeInTheDocument(); + // Text in the select box shows all layers are selected + expect(multiSelectHeader).toHaveTextContent('3 of 3 layers selected▼'); + }); + }); + + describe('\'Auto-scroll with media\' checkbox', () => { + beforeEach(() => { + render(); + }); + + test('is displayed checked on initial load', () => { + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + + const checkBox = screen.getByTestId('annotation-multi-select').childNodes[1]; + expect(checkBox).toHaveClass('ramp--annotations__scroll'); + expect(checkBox).toHaveTextContent('Auto-scroll with media'); + expect(within(checkBox).getByRole('checkbox')).toBeChecked(); + }); + + test('calls \'setAutoScrollEnabled\' when clicked', () => { + const checkBox = screen.getByTestId('annotation-multi-select').childNodes[1]; + expect(checkBox).toHaveClass('ramp--annotations__scroll'); + expect(checkBox).toHaveTextContent('Auto-scroll with media'); + + fireEvent.click(within(checkBox).getByRole('checkbox')); + + expect(setAutoScrollEnabledMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/MarkersDisplay/Annotations/AnnotationRow.js b/src/components/MarkersDisplay/Annotations/AnnotationRow.js index b71a461d..e997cba1 100644 --- a/src/components/MarkersDisplay/Annotations/AnnotationRow.js +++ b/src/components/MarkersDisplay/Annotations/AnnotationRow.js @@ -1,15 +1,33 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import PropTypes from 'prop-types'; -import { timeToHHmmss } from '@Services/utility-helpers'; +import cx from 'classnames'; +import { autoScroll, timeToHHmmss } from '@Services/utility-helpers'; import { useAnnotations, useMediaPlayer } from '@Services/ramp-hooks'; +import { SUPPORTED_MOTIVATIONS } from '@Services/annotations-parser'; -const AnnotationRow = ({ annotation, displayMotivations }) => { +const AnnotationRow = ({ + annotation, + displayMotivations, + autoScrollEnabled, + containerRef, + displayedAnnotations +}) => { const { id, canvasId, motivation, time, value } = annotation; - const { start, end } = time; - const { player } = useMediaPlayer(); - const { checkCanvas } = useAnnotations({ canvasId }); + const [isActive, setIsActive] = useState(false); + const [showLongText, setShowLongText] = useState(false); + + const { player, currentTime } = useMediaPlayer(); + const { checkCanvas, inPlayerRange } = useAnnotations({ + canvasId, + startTime: time?.start, + endTime: time?.end, + currentTime, + displayedAnnotations + }); + + const annotationRef = useRef(null); /** * Display only the annotations with at least one of the specified motivations @@ -24,25 +42,42 @@ const AnnotationRow = ({ annotation, displayMotivations }) => { }, [annotation]); /** - * Seek the player to; + * When there multiple annotations in the same time range, auto-scroll to + * the annotation with the start time that is closest to the current time + * of the player. + * This allows a better user experience when auto-scroll is enabled during playback, + * and there are multiple annotations that falls within the same time range. + */ + useEffect(() => { + inPlayerRange ? setIsActive(true) : setIsActive(false); + if (autoScrollEnabled && inPlayerRange) { + autoScroll(annotationRef.current, containerRef, true); + } + }, [inPlayerRange]); + + /** + * Click event handler for annotations displayed. + * An annotation can have links embedded in the text; and the click event's + * target is a link, then open the link in the same page. + * If the click event's target is the text or the timestamp of the + * annotation, then seek the player to; * - start time of an Annotation with a time range - * - timestamp of an Annotation with a single time-point - * on click event on each Annotation + * - timestamp of an Annotation with a single time-point. */ const handleOnClick = useCallback((e) => { e.preventDefault(); checkCanvas(); - const currentTime = start; + const currTime = time?.start; if (player) { const { start, end } = player.targets[0]; switch (true) { - case currentTime >= start && currentTime <= end: - player.currentTime(currentTime); + case currTime >= start && currTime <= end: + player.currentTime(currTime); break; - case currentTime < start: + case currTime < start: player.currentTime(start); break; - case currentTime > end: + case currTime > end: player.currentTime(end); break; } @@ -51,35 +86,62 @@ const AnnotationRow = ({ annotation, displayMotivations }) => { // Annotations with purpose tagging are displayed as tags next to time const tags = value.filter((v) => v.purpose.includes('tagging')); - // Annotations with purpose commenting/supplementing are displayed as text + // Annotations with purpose commenting/supplementing/transcribing are displayed as text const texts = value.filter( - (v) => v.purpose.includes('commenting') || v.purpose.includes('supplementing') + (v) => SUPPORTED_MOTIVATIONS.some(m => v.purpose.includes(m)) ); + /** + * Click event handler for the 'Show more'/'Show less' button for + * each annotation displayed. + */ + const handleShowMoreLessClick = () => { + if (!showLongText) { + // Show all lines on click of 'Show more' button + const hiddenTexts = annotationRef.current + .querySelectorAll('.ramp--annotations__annotation-text.hidden'); + hiddenTexts.forEach((text) => { + text.classList.remove('hidden'); + }); + } else { + // Show only the first 6 lines on click of 'Show less' button + const allTexts = annotationRef.current + .querySelectorAll('.ramp--annotations__annotation-text'); + allTexts.forEach((text, index) => { + if (index > 5) { + text.classList.add('hidden'); + } + }); + } + setShowLongText(!showLongText); + }; + if (canDisplay) { return (
  • - {start != undefined && ( + {time?.start != undefined && ( - {timeToHHmmss(start, true)} + data-testid="annotation-start-time"> + {timeToHHmmss(time?.start, true, true)} )} - {end != undefined && ( + {time?.end != undefined && ( - {` - ${timeToHHmmss(end, true)}`} + data-testid="annotation-end-time"> + {` - ${timeToHHmmss(time?.end, true, true)}`} )}
    @@ -89,6 +151,7 @@ const AnnotationRow = ({ annotation, displayMotivations }) => {

    {tag.value}

    @@ -96,15 +159,30 @@ const AnnotationRow = ({ annotation, displayMotivations }) => { })}
  • - {texts?.length > 0 && texts.map((text, index) => { - return ( -

    -

    - ); - })} +
    + {texts?.length > 0 && texts.map((text, index) => { + return ( +

    5 && "hidden" + )} + dangerouslySetInnerHTML={{ __html: text.value }}> +

    + ); + })} + {texts?.length > 6 && ( + + )} +
    ); } else { @@ -115,6 +193,9 @@ const AnnotationRow = ({ annotation, displayMotivations }) => { AnnotationRow.propTypes = { annotation: PropTypes.object.isRequired, displayMotivations: PropTypes.array.isRequired, + autoScrollEnabled: PropTypes.bool.isRequired, + containerRef: PropTypes.object.isRequired, + displayedAnnotations: PropTypes.array, }; export default AnnotationRow; diff --git a/src/components/MarkersDisplay/Annotations/AnnotationRow.test.js b/src/components/MarkersDisplay/Annotations/AnnotationRow.test.js new file mode 100644 index 00000000..1e275071 --- /dev/null +++ b/src/components/MarkersDisplay/Annotations/AnnotationRow.test.js @@ -0,0 +1,346 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import AnnotationRow from './AnnotationRow'; +import * as hooks from '@Services/ramp-hooks'; +import * as utils from '@Services/utility-helpers'; + +describe('AnnotationRow component', () => { + const checkCanvasMock = jest.fn(); + const playerCurrentTimeMock = jest.fn((time) => { return time; }); + // Mock custom hook output + jest.spyOn(hooks, 'useMediaPlayer').mockImplementation(() => ({ + currentTime: 0, + player: { currentTime: playerCurrentTimeMock, targets: [{ start: 10.23, end: 100.34 }] } + })); + jest.spyOn(hooks, 'useAnnotations').mockImplementation(() => ({ + checkCanvas: checkCanvasMock, + })); + const containerRef = { current: document.createElement('div') }; + const props = { displayMotivations: [], autoScrollEnabled: true, containerRef }; + + describe('with displayMotivations=[] (default)', () => { + test('displays annotation with \'supplementing\' motivation', () => { + const annotation = { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing'], + time: { start: 0, end: 10 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }] + }; + render(); + + expect(screen.getByTestId('annotation-row')).toBeInTheDocument(); + expect(screen.getByText('Men singing')).toBeInTheDocument(); + expect(screen.queryAllByTestId(/annotation-tag-*/).length).toBe(0); + expect(screen.getByTestId('annotation-start-time')).toHaveTextContent('00:00:00.000'); + expect(screen.getByTestId('annotation-end-time')).toHaveTextContent('00:00:10.000'); + }); + + test('displays annotation with \'commenting\' motivation', () => { + const annotation = { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['commenting'], + time: { start: 10, end: undefined }, + value: [{ format: 'text/plain', purpose: ['commenting'], value: 'Men singing' }] + }; + render(); + + expect(screen.getByTestId('annotation-row')).toBeInTheDocument(); + expect(screen.getByText('Men singing')).toBeInTheDocument(); + expect(screen.queryAllByTestId(/annotation-tag-*/).length).toBe(0); + expect(screen.getByTestId('annotation-start-time')).toHaveTextContent('00:00:10.000'); + expect(screen.queryByTestId('annotation-end-time')).not.toBeInTheDocument(); + }); + + describe('displays annotation tags for annotation with \'tagging\'', () => { + test('and \'supplementing\' motivations', () => { + const annotation = { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing', 'tagging'], + time: { start: 0, end: 10 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['tagging'], value: 'Music' }] + }; + render(); + + expect(screen.getByTestId('annotation-row')).toBeInTheDocument(); + expect(screen.getByText('Men singing')).toBeInTheDocument(); + expect(screen.queryAllByTestId(/annotation-tag-*/).length).toBe(1); + expect(screen.getByTestId('annotation-tag-0')).toHaveTextContent('Music'); + expect(screen.getByTestId('annotation-start-time')).toHaveTextContent('00:00:00.000'); + expect(screen.getByTestId('annotation-end-time')).toHaveTextContent('00:00:10.000'); + }); + + test('and \'commenting\' motivations', () => { + const annotation = { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['commenting', 'tagging'], + time: { start: 10, end: undefined }, + value: [{ format: 'text/plain', purpose: ['commenting'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['tagging'], value: 'Music' }] + }; + render(); + + expect(screen.getByTestId('annotation-row')).toBeInTheDocument(); + expect(screen.getByText('Men singing')).toBeInTheDocument(); + expect(screen.queryAllByTestId(/annotation-tag-*/).length).toBe(1); + expect(screen.getByTestId('annotation-tag-0')).toHaveTextContent('Music'); + expect(screen.getByTestId('annotation-start-time')).toHaveTextContent('00:00:10.000'); + expect(screen.queryByTestId('annotation-end-time')).not.toBeInTheDocument(); + }); + }); + }); + + test('displays HTML tags in the annotation textual body', () => { + const annotation = { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['commenting', 'tagging'], + time: { start: 10, end: undefined }, + value: [{ format: 'text/plain', purpose: ['commenting'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['tagging'], value: 'Music' }] + }; + render(); + + expect(screen.getByTestId('annotation-row')).toBeInTheDocument(); + // Do not display only plain text + expect(screen.queryByText('Men singing')).not.toBeInTheDocument(); + // Displays text with inline HTML + expect(screen.queryByTestId('annotation-text-0')).toBeInTheDocument(); + expect(screen.getByTestId('annotation-text-0').childNodes[0].tagName).toBe('STRONG'); + expect(screen.getByTestId('annotation-text-0')).toHaveTextContent('Men singing'); + }); + + describe('with displayMotivations=[\'supplementing\']', () => { + test('displays annotation with \'supplementing\' motivation', () => { + const annotation = { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing', 'tagging'], + time: { start: 0, end: 10 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['tagging'], value: 'Music' }] + }; + render(); + + expect(screen.getByTestId('annotation-row')).toBeInTheDocument(); + expect(screen.getByText('Men singing')).toBeInTheDocument(); + expect(screen.queryAllByTestId(/annotation-tag-*/).length).toBe(1); + expect(screen.getByTestId('annotation-tag-0')).toHaveTextContent('Music'); + expect(screen.getByTestId('annotation-start-time')).toHaveTextContent('00:00:00.000'); + expect(screen.getByTestId('annotation-end-time')).toHaveTextContent('00:00:10.000'); + }); + + test('does not display annotation with \'commenting\' motivation', () => { + const annotation = { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['commenting'], + time: { start: 10, end: undefined }, + value: [{ format: 'text/plain', purpose: ['commenting'], value: 'Men singing' }] + }; + render(); + expect(screen.queryByTestId('annotation-row')).not.toBeInTheDocument(); + }); + }); + + describe('clicking an annotation row', () => { + const annotation = { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing', 'tagging'], + time: { start: 25.32, end: 45.65 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['tagging'], value: 'Music' }] + }; + + test('sets player\'s currentTime when time is within the duration of the media', () => { + // Mock imported autoScroll function + const autoScrollMock = jest.spyOn(utils, 'autoScroll').mockImplementationOnce(jest.fn()); + // Mock useAnnotation hook to inPlayerRange=true + jest.spyOn(hooks, 'useAnnotations').mockImplementation(() => ({ + checkCanvas: checkCanvasMock, + inPlayerRange: true, + })); + + render(); + + expect(screen.getByTestId('annotation-row')).toBeInTheDocument(); + expect(screen.getByTestId('annotation-text-0')).toHaveTextContent('Men singing'); + expect(screen.getByTestId('annotation-tag-0')).toHaveTextContent('Music'); + expect(screen.getByTestId('annotation-start-time')).toHaveTextContent('00:00:25.32'); + expect(screen.getByTestId('annotation-end-time')).toHaveTextContent('00:00:45.65'); + + fireEvent.click(screen.getByTestId('annotation-row')); + + expect(playerCurrentTimeMock).toHaveBeenCalledTimes(1); + expect(playerCurrentTimeMock).toHaveBeenCalledWith(25.32); + expect(autoScrollMock).toHaveBeenCalledTimes(1); + }); + + test('sets player to start of the media when annotation start time < media start time', () => { + render(); + + expect(screen.getByTestId('annotation-row')).toBeInTheDocument(); + expect(screen.getByTestId('annotation-text-0')).toHaveTextContent('Men singing'); + expect(screen.getByTestId('annotation-tag-0')).toHaveTextContent('Music'); + expect(screen.getByTestId('annotation-start-time')).toHaveTextContent('00:00:00.000'); + expect(screen.getByTestId('annotation-end-time')).toHaveTextContent('00:00:10.000'); + + fireEvent.click(screen.getByTestId('annotation-row')); + + expect(playerCurrentTimeMock).toHaveBeenCalledTimes(1); + expect(playerCurrentTimeMock).toHaveBeenCalledWith(10.23); + }); + + test('sets player to end of the media when annotation start time > media duration', () => { + render(); + + expect(screen.getByTestId('annotation-row')).toBeInTheDocument(); + expect(screen.getByTestId('annotation-text-0')).toHaveTextContent('Men singing'); + expect(screen.getByTestId('annotation-tag-0')).toHaveTextContent('Music'); + expect(screen.getByTestId('annotation-start-time')).toHaveTextContent('00:01:41.32'); + expect(screen.getByTestId('annotation-end-time')).toHaveTextContent('00:01:50.56'); + + fireEvent.click(screen.getByTestId('annotation-row')); + + expect(playerCurrentTimeMock).toHaveBeenCalledTimes(1); + expect(playerCurrentTimeMock).toHaveBeenCalledWith(100.34); + }); + }); + + describe('displays annotations with longer texts(TextualBody count > 6)', () => { + beforeEach(() => { + const annotation = { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing'], + time: { start: 0, end: 10 }, + value: [ + { format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }, + ] + }; + render(); + }); + + test('truncated with \'Show more\' button', () => { + expect(screen.getByTestId('annotation-row')).toBeInTheDocument(); + expect(screen.queryAllByTestId(/annotation-tag-*/).length).toBe(0); + + expect(screen.getByTestId('annotation-start-time')).toHaveTextContent('00:00:00.000'); + expect(screen.queryByTestId('annotation-end-time')).toHaveTextContent('00:00:10.000'); + + expect(screen.queryAllByText('Men singing').length).toBeGreaterThan(0); + + const textElements = screen.queryAllByText('Men singing'); + + // The 0-5 transcript line texts are not hidden from view + expect(textElements[0]).toHaveClass('ramp--annotations__annotation-text'); + expect(textElements[5]).toHaveClass('ramp--annotations__annotation-text'); + + // The sixth transcript line text is hidden from view + expect(textElements[6]).toHaveClass('ramp--annotations__annotation-text hidden'); + + expect(screen.queryAllByTestId(/annotation-show-more-*/).length).toBe(1); + expect(screen.queryByText('Show more')).toBeInTheDocument(); + }); + + test('truncated and can be expanded and collapsed', () => { + expect(screen.queryAllByText('Men singing').length).toBeGreaterThan(0); + const textElements = screen.queryAllByText('Men singing'); + + // The 0-5 transcript line texts are not hidden from view + expect(textElements[0]).toHaveClass('ramp--annotations__annotation-text'); + expect(textElements[5]).toHaveClass('ramp--annotations__annotation-text'); + // The sixth transcript line text is hidden from view + expect(textElements[6]).toHaveClass('ramp--annotations__annotation-text hidden'); + + expect(screen.queryAllByTestId(/annotation-show-more-*/).length).toBe(1); + expect(screen.queryByText('Show more')).toBeInTheDocument(); + + // Click 'Show more' button + fireEvent.click(screen.getByTestId( + 'annotation-show-more-http://example.com/manifest/canvas/1/annotation-page/1/annotation/1' + )); + + // Text on the button is changed to 'Show less' + expect(screen.queryByText('Show more')).not.toBeInTheDocument(); + expect(screen.queryByText('Show less')).toBeInTheDocument(); + + // The 0-5 transcript line texts are still not hidden from view + expect(textElements[0]).toHaveClass('ramp--annotations__annotation-text'); + expect(textElements[5]).toHaveClass('ramp--annotations__annotation-text'); + // The sixth transcript line text is not hidden from view now + expect(textElements[6]).toHaveClass('ramp--annotations__annotation-text'); + + // Click 'Show less' button + fireEvent.click(screen.getByTestId( + 'annotation-show-more-http://example.com/manifest/canvas/1/annotation-page/1/annotation/1' + )); + + // Text on the button is changed to 'Show more' + expect(screen.queryByText('Show more')).toBeInTheDocument(); + expect(screen.queryByText('Show less')).not.toBeInTheDocument(); + + // The 0-5 transcript line texts are still not hidden from view + expect(textElements[0]).toHaveClass('ramp--annotations__annotation-text'); + expect(textElements[5]).toHaveClass('ramp--annotations__annotation-text'); + // The sixth transcript line text is hidden from view now + expect(textElements[6]).toHaveClass('ramp--annotations__annotation-text hidden'); + }); + }); + + test('does not display \'Show more\' button for annotations with TextualBody count < 6', () => { + const annotation = { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['commenting', 'tagging'], + time: { start: 10, end: undefined }, + value: [ + { format: 'text/plain', purpose: ['commenting'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['commenting'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['commenting'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['commenting'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['commenting'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['commenting'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['tagging'], value: 'Music' } + ] + }; + render(); + + expect(screen.getByTestId('annotation-row')).toBeInTheDocument(); + expect(screen.queryAllByText('Men singing').length).toBeGreaterThan(0); + + expect(screen.queryAllByTestId(/annotation-show-more-*/).length).toBe(0); + + const textElements = screen.queryAllByText('Men singing'); + + // None of the transcript texts are not hidden from view + expect(textElements[0]).toHaveClass('ramp--annotations__annotation-text'); + expect(textElements[5]).toHaveClass('ramp--annotations__annotation-text'); + }); +}); diff --git a/src/components/MarkersDisplay/Annotations/AnnotationsDisplay.js b/src/components/MarkersDisplay/Annotations/AnnotationsDisplay.js index 1ab28881..70101568 100644 --- a/src/components/MarkersDisplay/Annotations/AnnotationsDisplay.js +++ b/src/components/MarkersDisplay/Annotations/AnnotationsDisplay.js @@ -1,13 +1,32 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import AnnotationLayerSelect from './AnnotationLayerSelect'; import '../MarkersDisplay.scss'; import AnnotationRow from './AnnotationRow'; import { sortAnnotations } from '@Services/utility-helpers'; +import Spinner from '@Components/Spinner'; const AnnotationsDisplay = ({ annotations, canvasIndex, duration, displayMotivations }) => { const [canvasAnnotationLayers, setCanvasAnnotationLayers] = useState([]); const [displayedAnnotationLayers, setDisplayedAnnotationLayers] = useState([]); + const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); + const [isLoading, setIsLoading] = useState(true); + + const annotationDisplayRef = useRef(null); + + /** + * Update annotation sets for the current Canvas + */ + useEffect(() => { + // Re-set isLoading on Canvas change + setIsLoading(true); + + if (annotations?.length > 0) { + const { _, annotationSets } = annotations + .filter((a) => a.canvasIndex === canvasIndex)[0]; + setCanvasAnnotationLayers(annotationSets); + } + }, [annotations, canvasIndex]); /** * Filter and merge annotations parsed from either an AnnotationPage or a linked @@ -20,6 +39,37 @@ const AnnotationsDisplay = ({ annotations, canvasIndex, duration, displayMotivat : []; }, [displayedAnnotationLayers]); + /** + * Identify any of the displayed annotation layers have linked resource(s). + * This value is used to initiate a delayed state update to the 'isLoading' + * variable, to stop displaying a no annotations message while fetch requests + * are in progress. + */ + const hasExternalAnnotations = useMemo(() => { + return displayedAnnotationLayers?.length > 0 + ? displayedAnnotationLayers.map((a) => a.linkedResource).reduce((acc, curr) => { + return acc || curr; + }, false) + : false; + }, [displayedAnnotationLayers]); + + /** + * Set timeout function with an AbortController + * @param {Function} callback + * @param {Number} delay milliseconds number to wait + * @param {Object} signal abort signal from AbortController + */ + const setTimeoutWithAbort = (callback, delay, signal) => { + if (signal?.aborted) { return; } + const timeOutId = setTimeout(() => { + if (!signal?.aborted) { callback(); } + }, delay); + // Listener to abort signal to clear existing timeout + signal?.addEventListener('abort', () => { + clearTimeout(timeOutId); + }); + }; + /** * Check if the annotations related to the Canvas have motivation(s) specified * by the user when the component is initialized. @@ -27,24 +77,79 @@ const AnnotationsDisplay = ({ annotations, canvasIndex, duration, displayMotivat * motivation(s), then a message is displayed to the user. */ const hasDisplayAnnotations = useMemo(() => { + // AbortController for timeout function to toggle 'isLoading' + let abortController; if (displayedAnnotations?.length > 0 && displayedAnnotations[0] != undefined) { + // If annotations are read before executing the timeout in the else condition, + // abort the timeout + abortController?.abort(); + // Once annotations are present remove the Spinner + setIsLoading(false); const motivations = displayedAnnotations.map((a) => a.motivation); return displayMotivations?.length > 0 - ? displayMotivations.some(m => motivations.includes(m)) + ? displayMotivations.some(m => motivations.flat().includes(m)) : true; + } else { + // Abort existing abortControll before creating a new one + abortController?.abort(); + /** + * Initiate a delayed call to toggle 'isLoading' with an abortController. + * This allows the UI to wait for annotations from any linked resources before + * displaying a no annotations message while the fetch requests are in progress. + */ + abortController = new AbortController(); + if (hasExternalAnnotations) { + setTimeoutWithAbort(() => { + setIsLoading(false); + }, 500, abortController.signal); + } + return false; } }, [displayedAnnotations]); - /** - * Update annotation sets for the current Canvas - */ - useEffect(() => { - if (annotations?.length > 0) { - const { _, annotationSets } = annotations - .filter((a) => a.canvasIndex === canvasIndex)[0]; - setCanvasAnnotationLayers(annotationSets); + const annotationLayerSelect = useMemo(() => { + return (); + }, [canvasAnnotationLayers]); + + const annotationRows = useMemo(() => { + if (isLoading) { + return ; + } else { + if (hasDisplayAnnotations && displayedAnnotations?.length > 0) { + return ( +
      + {displayedAnnotations.map((annotation, index) => { + return ( + + ); + })} +
    + ); + } else { + return ( +

    + {displayMotivations?.length > 0 + ? `No Annotations were found with ${displayMotivations.join('/')} motivation.` + : 'No Annotations were found for the selected layer(s).'} +

    + ); + } } - }, [annotations, canvasIndex]); + }, [hasDisplayAnnotations, displayedAnnotations, isLoading]); if (canvasAnnotationLayers?.length > 0) { return ( @@ -52,33 +157,20 @@ const AnnotationsDisplay = ({ annotations, canvasIndex, duration, displayMotivat data-testid="annotations-display">
    - + {annotationLayerSelect}
    -
    - {hasDisplayAnnotations && displayedAnnotations != undefined && displayedAnnotations?.length > 0 && ( -
      - {displayedAnnotations.map((annotation, index) => { - return ( - - ); - })} -
    - ) - } - {!hasDisplayAnnotations && displayMotivations?.length != 0 && ( -

    {`No Annotations with ${displayMotivations.join('/')} motivation.`}

    - )} +
    + {annotationRows}
    ); + } else { + return ( +

    + No Annotations layers were found for the Canvas. +

    + ); } }; diff --git a/src/components/MarkersDisplay/Annotations/AnnotationsDisplay.test.js b/src/components/MarkersDisplay/Annotations/AnnotationsDisplay.test.js new file mode 100644 index 00000000..2d5534bd --- /dev/null +++ b/src/components/MarkersDisplay/Annotations/AnnotationsDisplay.test.js @@ -0,0 +1,446 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import AnnotationsDisplay from './AnnotationsDisplay'; +import * as hooks from '@Services/ramp-hooks'; +import * as annotationParser from '@Services/annotations-parser'; + +/** + * Value for prop 'annotations' with linked resources for annotation layers. + * The first annotationLayer is populated with the 'items' list to mimic the + * behavior in the interface, since the initial 'useEffect' doesn't get + * invoked in test environment. + */ +const linkedAnnotationLayers = [ + { + canvasIndex: 0, + annotationSets: [ + { + canvasId: 'http://example.com/manifest/canvas/1', + format: 'text/vtt', + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + label: 'Captions in English.vtt', + linkedResource: true, + motivation: ['supplementing'], + url: 'http://example.com/manifest/files/captions-in-english.vtt', + items: [ + { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing'], + time: { start: 7, end: undefined }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }] + }, + { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing'], + time: { start: 25.32, end: 27.65 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'The Yale Glee Club singing "Mother of Men"' }] + }, + { + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing'], + time: { start: 29.54, end: 45.32 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Subjects: Singing' }] + } + ], + }, + { + canvasId: 'http://example.com/manifest/canvas/1', + format: 'text/srt', + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/2', + label: 'Subtitle in English.srt', + linkedResource: true, + motivation: ['supplementing'], + url: 'http://example.com/manifest/files/subtitles-in-english.srt', + }, + ] + }, + { + canvasIndex: 1, + annotationSets: [] + }, + { + canvasIndex: 2, + annotationSets: [ + { + canvasId: 'http://example.com/manifestcanvas/3', + format: 'text/vtt', + id: 'http://example.com/manifest/canvas/3/annotation-page/1/annotation/1', + label: 'Captions in English - Canvas 2.vtt', + linkedResource: true, + motivation: ['supplementing'], + url: 'http://example.com/manifest/files/captions-in-english.vtt', + items: [], + }, + { + canvasId: 'http://example.com/manifest/canvas/3', + format: 'text/srt', + id: 'http://example.com/manifest/canvas/3/annotation-page/1/annotation/2', + label: 'Subtitle in English - Canvas 2.srt', + linkedResource: true, + motivation: ['supplementing'], + url: 'http://example.com/manifestfiles/subtitles-in-english.srt', + }, + ] + }, +]; + +/** + * Value for prop 'annotations' with a mix of external AnnotationPage resources and + * AnnotationPage with inline annotations for annotation layers. + * The first annotationLayer (Songs) in the list alphabetically, is setup as an + * annotationLayer with inline annotations for easier testing. + */ +const annotationLayers = [ + { + canvasIndex: 0, + annotationSets: [ + { + label: 'Unknown', + format: 'application/json', + url: 'http://example.com/manifestannotation-page/unknown.json', + }, + { + label: 'Songs', + items: [{ + id: 'songs-annotation-0', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing', 'tagging'], + time: { start: 7, end: undefined }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }, + { format: 'text/plain', purpose: ['tagging'], value: 'Songs' }] + }, + { + id: 'songs-annotation-1', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing', 'tagging'], + time: { start: 25.32, end: 27.65 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'The Yale Glee Club singing "Mother of Men"' }, + { format: 'text/plain', purpose: ['tagging'], value: 'Songs' }] + }, + { + id: 'songs-annotation-2', + canvasId: 'http://example.com/manifest/canvas/1', + motivation: ['supplementing', 'tagging'], + time: { start: 29.54, end: 45.32 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Subjects: Singing' }, + { format: 'text/plain', purpose: ['tagging'], value: 'Songs' }] + }], + }, + { + label: 'Texts', + format: 'application/json', + url: 'http://example.com/manifestannotation-page/texts.json', + } + ] + } +]; + +/** + * Sample response for a fetch request for a linked AnnotationPage with annotations. + */ +const annotationPageResponse = { + "@context": "http://iiif.io/api/presentation/3/context.json", + "id": "http://example.com/manifest/annotations/unknown.json", + "type": "AnnotationPage", "label": "Unknown", + "items": [ + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "unknown-annotation-1.json", + "type": "Annotation", + "motivation": ["commenting"], + "body": [ + { "type": "TextualBody", "value": "Savannah, GA", "format": "text/plain", "purpose": "commenting" }, + { "type": "TextualBody", "value": "Unknown", "format": "text/plain", "purpose": "tagging" } + ], + "target": { + "source": { + "id": "http://example.com/manifest/canvas-1/canvas", + "type": "Canvas", + }, + "selector": { "type": "PointSelector", "t": "2766.438533" } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "unknown-annotation-2.json", + "type": "Annotation", + "motivation": ["supplementing", "commenting"], + "body": [ + { "type": "TextualBody", "value": "A play that we used to play when we were children in Savannah.", "format": "text/plain", "purpose": "commenting" }, + { "type": "TextualBody", "value": "Unknown", "format": "text/plain", "purpose": "tagging" } + ], + "target": { + "source": { + "id": "http://example.com/manifest/canvas-1/canvas", + "type": "Canvas", + }, + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "t=2771.900826,2775.619835" + } + } + }, + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "unknown-annotation-3.json", + "type": "Annotation", + "motivation": ["supplementing", "commenting"], + "body": [ + { "type": "TextualBody", "value": "A ring play, just a ring play, a children's ring play", "format": "text/plain", "purpose": "commenting" }, + { "type": "TextualBody", "value": "Unknown", "format": "text/plain", "purpose": "tagging" } + ], + "target": { + "source": { + "id": "http://example.com/manifest/canvas-1/canvas", + "type": "Canvas", + }, + "selector": { + "type": "FragmentSelector", + "conformsTo": "http://www.w3.org/TR/media-frags/", + "value": "t=2779.493802,2782.438017" + } + } + } + ] +}; + +describe('AnnotationsDisplay component', () => { + const checkCanvasMock = jest.fn(); + const playerCurrentTimeMock = jest.fn((time) => { return time; }); + + beforeEach(() => { + jest.clearAllMocks(); + // Mock custom hook output + jest.spyOn(hooks, 'useMediaPlayer').mockImplementation(() => ({ + currentTime: 0, + player: { currentTime: playerCurrentTimeMock, targets: [{ start: 10.23, end: 100.34 }] } + })); + jest.spyOn(hooks, 'useAnnotations').mockImplementation(() => ({ + checkCanvas: checkCanvasMock + })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('displays a message when annotation layers list is empty', () => { + render(); + + expect(screen.queryByTestId('annotations-display')).not.toBeInTheDocument(); + expect(screen.queryByTestId('no-annotation-layers-message')).toBeInTheDocument(); + expect(screen.queryByText('No Annotations layers were found for the Canvas.')).toBeInTheDocument(); + }); + + test('displays a message when there are no annotation layers for the current Canvas', () => { + render(); + + expect(screen.queryByTestId('annotations-display')).not.toBeInTheDocument(); + expect(screen.queryByTestId('no-annotation-layers-message')).toBeInTheDocument(); + expect(screen.queryByText('No Annotations layers were found for the Canvas.')).toBeInTheDocument(); + }); + + test('displays annotation selection when there are annotation layers for the current Canvas', async () => { + jest + .spyOn(annotationParser, 'parseExternalAnnotationResource') + .mockResolvedValueOnce([ + { + canvasId: 'http://example.com/manifest/canvas/1', + id: 'http://example.com/manifest/canvas/1/annotation-page/1/annotation/1', + motivation: ['supplementing'], + time: { start: 1.20, end: 21 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: '[music]' }], + } + ]); + render(); + await act(() => Promise.resolve()); + + expect(screen.queryByTestId('annotations-display')).toBeInTheDocument(); + expect(screen.queryByText('Annotation layers:')).toBeInTheDocument(); + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + expect(screen.getByTestId('annotation-multi-select').childNodes[0]) + .toHaveTextContent('1 of 2 layers selected▼'); + expect(screen.queryByTestId('annotations-content')).toBeInTheDocument(); + }); + + describe('for a selected annotation layer', () => { + test('displays annotations with same motivation as \'displayMotivations\'', async () => { + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + status: 201, + ok: true, + json: jest.fn(() => { return annotationPageResponse; }) + }); + + render(); + + await act(() => Promise.resolve()); + + const multiSelect = screen.queryByTestId('annotation-multi-select'); + const multiSelectHeader = multiSelect.childNodes[0]; + + // Only one annotation layer is selected initially + expect(multiSelectHeader).toHaveTextContent('1 of 3 layers selected▼'); + expect(screen.queryAllByTestId('annotation-row').length).toEqual(3); + + // Open the annotation layers list dropdown + fireEvent.click(multiSelectHeader); + + const annotationLlist = multiSelect.childNodes[1]; + + // Check the second annotation layer is not selected + expect(annotationLlist.childNodes[3]).toHaveTextContent('Unknown'); + expect(within(annotationLlist.childNodes[3]).getByRole('checkbox')).not.toBeChecked(); + + // Select the 'Unknown' annotation layer + const checkBox = screen.queryAllByRole('checkbox')[3]; + // Wrap in act() to ensure all state updates are processed before assertions are run. + await act(() => { + fireEvent.click(checkBox); + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(multiSelectHeader).toHaveTextContent('2 of 3 layers selected▼'); + expect(screen.queryAllByTestId('annotation-row').length).toEqual(5); + }); + + test('displays all annotations when \'displayMotivations\' is empty', async () => { + const mockResponse = + 'WEBVTT\r\n\r\n1\r\n00:00:01.200 --> 00:00:21.000\n[music]\n\r\n2\r\n00:00:22.200 --> 00:00:26.600\nJust before lunch one day, a puppet show \nwas put on at school.\n\r\n3\r\n00:00:26.700 --> 00:00:31.500\nIt was called "Mister Bungle Goes to Lunch".\n\r\n4\r\n00:00:31.600 --> 00:00:34.500\nIt was fun to watch.\n\r\n5\r\n00:00:36.100 --> 00:00:41.300\nIn the puppet show, Mr. Bungle came to the \nboys\' room on his way to lunch.\n'; + const fetchWebVTT = jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + status: 200, + ok: true, + headers: { get: jest.fn(() => 'text/vtt') }, + text: jest.fn(() => mockResponse), + }); + + render(); + + const multiSelect = screen.queryByTestId('annotation-multi-select'); + const multiSelectHeader = multiSelect.childNodes[0]; + + // Only one annotation layer is selected initially + expect(multiSelectHeader).toHaveTextContent('1 of 2 layers selected▼'); + + // Open the annotation layers list dropdown + fireEvent.click(multiSelectHeader); + + const annotationLlist = multiSelect.childNodes[1]; + + // Check the second annotation layer is not selected + expect(annotationLlist.childNodes[2]).toHaveTextContent('Subtitle in English.srt'); + expect(within(annotationLlist.childNodes[2]).getByRole('checkbox')).not.toBeChecked(); + + // Select the 'Subtitle in English.srt' annotation layer + const checkBox = screen.queryAllByRole('checkbox')[2]; + // Wrap in act() to ensure all state updates are processed before assertions are run. + await act(() => { + fireEvent.click(checkBox); + }); + + await waitFor(() => { + expect(multiSelectHeader).toHaveTextContent('2 of 2 layers selected▼'); + expect(screen.queryAllByTestId('annotation-row').length).toEqual(8); + expect(fetchWebVTT).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('displays a message when there are no annotations', () => { + let parseExternalAnnotationResourceMock; + + beforeEach(() => { + jest.clearAllMocks(); + parseExternalAnnotationResourceMock = jest + .spyOn(annotationParser, 'parseExternalAnnotationResource'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('for filtered motivations', async () => { + parseExternalAnnotationResourceMock.mockResolvedValueOnce([ + { + canvasId: 'http://example.com/manifestcanvas/1', + id: 'http://example.com/manifestcanvas/1/annotation-page/1/annotation/1', + motivation: ['supplementing'], + time: { start: 1.20, end: 21 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: '[music]' }], + } + ]); + + render(); + + await act(() => Promise.resolve()); + + await waitFor(() => { + expect(screen.queryByTestId('annotations-display')).toBeInTheDocument(); + expect(screen.queryByText('Annotation layers:')).toBeInTheDocument(); + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + expect(screen.queryByTestId('annotations-content')).toBeInTheDocument(); + expect(screen.queryByTestId('no-annotations-message')).toBeInTheDocument(); + expect(screen.queryByText( + 'No Annotations were found with commenting motivation.' + )).toBeInTheDocument(); + }); + }); + + test('for empty list of displayMotivations', async () => { + parseExternalAnnotationResourceMock.mockResolvedValueOnce([]); + + render(); + + await act(async () => { Promise.resolve(); }); + + await waitFor(() => { + expect(screen.queryByTestId('annotations-display')).toBeInTheDocument(); + expect(screen.queryByText('Annotation layers:')).toBeInTheDocument(); + expect(screen.queryByTestId('annotation-multi-select')).toBeInTheDocument(); + expect(screen.queryByTestId('annotations-content')).toBeInTheDocument(); + expect(screen.queryByTestId('no-annotations-message')).toBeInTheDocument(); + expect(screen.queryByText( + 'No Annotations were found for the selected layer(s).' + )).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/components/MarkersDisplay/MarkersDisplay.scss b/src/components/MarkersDisplay/MarkersDisplay.scss index 12b382ba..d1a4f124 100644 --- a/src/components/MarkersDisplay/MarkersDisplay.scss +++ b/src/components/MarkersDisplay/MarkersDisplay.scss @@ -144,7 +144,7 @@ flex-direction: column; background-color: $primaryLightest; - .ramp--annotatations__multi-select { + .ramp--annotations__multi-select { position: relative; font-family: Arial, sans-serif; } @@ -179,7 +179,7 @@ padding: 0; list-style-type: none; position: absolute; - top: 100%; + top: auto; left: 0; width: 100%; border: 1px solid #ccc; @@ -219,15 +219,12 @@ cursor: pointer; padding: 10px; - &:hover, - &:focus { - background-color: $primaryGreenLight; + &.active { + background-color: $primaryLight; } - &.focused, - &.focused:hover, - &.focused:focus { - background-color: $primaryGreenSemiLight; + &:hover { + background-color: $primaryGreenLight; } .ramp--annotations__annotation-row-time-tags { @@ -249,9 +246,31 @@ } } - p.ramp--annotations__annotation-text { - margin: 0; + .ramp--annotations__annotation-texts { + display: flex; + flex-direction: column; margin-top: 0.5em; + + :last-child { + margin-left: auto; + } + + p.ramp--annotations__annotation-text { + margin: 0; + margin-top: 0.5em; + + &.hidden { + display: none; + } + } + + .ramp--annotations__show-more-less { + font-size: small; + cursor: pointer; + background: none; + border: 1px solid $primaryDarker; + border-radius: 3px; + } } } } diff --git a/src/components/MediaPlayer/VideoJS/VideoJSPlayer.js b/src/components/MediaPlayer/VideoJS/VideoJSPlayer.js index d57eb7fd..a31262c8 100644 --- a/src/components/MediaPlayer/VideoJS/VideoJSPlayer.js +++ b/src/components/MediaPlayer/VideoJS/VideoJSPlayer.js @@ -250,8 +250,11 @@ function VideoJSPlayer({ * of the browser, or network latency. * This code helps to store the seeked time in these scenarios and re-seek the player to the initial * seeked time-point on player.load() call. + * Additional check for player.readyState() != 4 is to avoid this code block from executing when using + * seek action to navigate to a timepoint in Annotations. */ - if (player.currentTime() == 0 && player.currentTime() != currentTimeRef.current) { + if (player.readyState() != 4 && player.currentTime() == 0 + && player.currentTime() != currentTimeRef.current) { player.currentTime(currentTimeRef.current); } // Update global state with the current time from 'seek' action diff --git a/src/services/annotation-parser.test.js b/src/services/annotation-parser.test.js index 7d506cb1..fd2685e7 100644 --- a/src/services/annotation-parser.test.js +++ b/src/services/annotation-parser.test.js @@ -437,7 +437,6 @@ const linkedExternalAnnotations = { ] }; - // Manifest with inline TextualBody annotations with // label/value and multiple annotations with same timestamps const aviaryTextualBodyAnnotations = { diff --git a/src/services/annotations-parser.js b/src/services/annotations-parser.js index 8880b024..eecc2f59 100644 --- a/src/services/annotations-parser.js +++ b/src/services/annotations-parser.js @@ -2,6 +2,7 @@ import { getCanvasId } from "./iiif-parser"; import { parseTranscriptData } from "./transcript-parser"; import { getLabelValue, getMediaFragment, handleFetchErrors, + identifySupplementingAnnotation, parseTimeStrings, sortAnnotations } from "./utility-helpers"; @@ -14,6 +15,10 @@ let TAG_COLORS = []; */ const TIME_SYNCED_FORMATS = ['text/vtt', 'text/srt', 'application/json']; +// Supported motivations for annotations +// Remove 'transcribing' once testing for Aviary manifests are completed. +export const SUPPORTED_MOTIVATIONS = ['commenting', 'supplementing', 'transcribing']; + /** * Parse annotation sets relevant to the current Canvas in a * given Manifest. @@ -119,13 +124,16 @@ function parseAnnotationPages(annotationPages, duration) { // Only add WebVTT, SRT, and JSON files as annotations const timeSynced = TIME_SYNCED_FORMATS.includes(body.format); if (timeSynced) { - annotationSet = { - ...parseAnnotationBody(body, annotationMotivation)[0], - canvasId: target, - id: id, - motivation: annotationMotivation, - }; - annotationSets.push(annotationSet); + const annotationInfo = parseAnnotationBody(body, annotationMotivation)[0]; + if (annotationInfo != undefined) { + annotationSet = { + ...annotationInfo, + canvasId: target, + id: id, + motivation: annotationMotivation, + }; + annotationSets.push(annotationSet); + } } }); } else { @@ -245,14 +253,12 @@ function parseSelector(selector, duration) { function parseTextualBody(textualBody, motivations) { let annotationBody = {}; let tagColor; - // List of motivations that is displayed as text in the UI - const textualMotivations = ['commenting', 'supplementing']; if (textualBody) { const { format, label, motivation, purpose, value } = textualBody; let annotationPurpose = purpose != undefined ? purpose : motivation; - if (annotationPurpose == undefined && textualMotivations.some(m => motivations.includes(m))) { + if (annotationPurpose == undefined && SUPPORTED_MOTIVATIONS.some(m => motivations.includes(m))) { // Filter only the motivations that are displayed as texts - annotationPurpose = motivations.filter((m) => textualMotivations.includes(m)); + annotationPurpose = motivations.filter((m) => SUPPORTED_MOTIVATIONS.includes(m)); } // If a label is given; combine label/value pairs to display @@ -306,16 +312,20 @@ function parseAnnotationBody(annotationBody, motivations) { break; case 'Text': const { format, id, label } = body; - values.push({ - format: format, - label: getLabelValue(label), - url: id, - /** - * 'linkedResource' property helps to make parsing the choice in - * 'fetchAndParseLinkedAnnotations()' in AnnotationLayerSelect. - */ - linkedResource: format != 'application/json', - }); + // Only use linked annotations with 'transcripts' type in Avalon manifests + let sType = identifySupplementingAnnotation(id); + if (sType !== 2) { + values.push({ + format: format, + label: getLabelValue(label), + url: id, + /** + * 'linkedResource' property helps to make parsing the choice in + * 'fetchAndParseLinkedAnnotations()' in AnnotationLayerSelect. + */ + linkedResource: format != 'application/json', + }); + } break; } }); diff --git a/src/services/ramp-hooks.js b/src/services/ramp-hooks.js index 2e7de6ac..e2c8cedb 100644 --- a/src/services/ramp-hooks.js +++ b/src/services/ramp-hooks.js @@ -13,6 +13,7 @@ import { parseTranscriptData, readSupplementingAnnotations, sanitizeTranscripts, import { CANVAS_MESSAGE_TIMEOUT, checkSrcRange, getMediaFragment, HOTKEY_ACTION_OUTPUT, playerHotKeys } from '@Services/utility-helpers'; import { getMediaInfo } from '@Services/iiif-parser'; import videojs from 'video.js'; +import throttle from 'lodash/throttle'; /** * Disable each marker when one of the markers in the table @@ -53,6 +54,11 @@ export const useMediaPlayer = () => { const { player } = playerState; const { allCanvases, canvasIndex, canvasIsEmpty } = manifestState; + const [currentTime, _setCurrentTime] = useState(-1); + const setCurrentTime = useMemo(() => throttle(_setCurrentTime, 50), []); + + const playerRef = useRef(null); + // Deduct 1 from length to compare against canvasIndex, which starts from 0 const lastCanvasIndex = useMemo(() => { return allCanvases?.length - 1 ?? 0; }, [allCanvases]); @@ -68,9 +74,26 @@ export const useMediaPlayer = () => { } }, [player]); + /** + * Listen to player's timeupdate event to update currentTime. + * 'currentTime' value is used in AnnotationRow component to update active + * annotation-row. + */ + useEffect(() => { + if (manifestState && playerState) { + playerRef.current = playerState.player; + } + if (playerRef.current) { + playerRef.current.on('timeupdate', () => { + setCurrentTime(playerRef.current.currentTime()); + }); + } + }, [manifestState]); + return { canvasIndex, canvasIsEmpty, + currentTime, isMultiCanvased, lastCanvasIndex, player, @@ -1077,12 +1100,17 @@ export const useTranscripts = ({ /** * Global state handling related to annotations display * @param {Object} obj - * @param {String} obj.canvasId + * @param {String} obj.canvasId + * @param {Number} obj.startTime + * @param {Number} obj.endTime + * @param {Number} obj.currentTime + * @param {Array} obj.displayedAnnotations * @returns { - * checkCanvas + * checkCanvas, + * inPlayerRange, * } */ -export const useAnnotations = ({ canvasId }) => { +export const useAnnotations = ({ canvasId, startTime, endTime, currentTime, displayedAnnotations = [] }) => { const manifestState = useContext(ManifestStateContext); const manifestDispatch = useContext(ManifestDispatchContext); @@ -1106,5 +1134,37 @@ export const useAnnotations = ({ canvasId }) => { } }, [isCurrentCanvas]); - return { checkCanvas }; + /** + * Use the current annotation's startTime and endTime in comparison with the startTime + * of the next annotation in the list to mark an annotation as active. + * When auto-scrolling is enabled, this is used by the AnnotationRow component to + * highlight and scroll the active annotation to the top of the container. + */ + const inPlayerRange = useMemo(() => { + // Index of the current annotation + const activeAnnotationIndex = displayedAnnotations + .findIndex((a) => a.time?.start === startTime); + // Retrieve the next annotation in the list + const nexAnnotation = activeAnnotationIndex < displayedAnnotations?.length + ? displayedAnnotations[activeAnnotationIndex + 1] + : undefined; + // If there's a next annotation, retrieve its start time + const nextAnnotationStartTime = nexAnnotation != undefined + ? nexAnnotation.time?.start : undefined; + + /** + * Check if the currentTime is within the range of the current annotation's startTime + * OR if the currentTime is before the startTime of the next annotation and within the + * range of the current annotation's start and end times. + */ + if (Math.floor(startTime) === Math.floor(currentTime) + || (nextAnnotationStartTime != undefined && currentTime < nextAnnotationStartTime + && startTime <= currentTime && currentTime <= endTime)) { + return true; + } else { + return false; + } + }, [currentTime, displayedAnnotations]); + + return { checkCanvas, inPlayerRange }; }; diff --git a/src/services/ramp-hooks.test.js b/src/services/ramp-hooks.test.js index 1940399b..b6dd4f87 100644 --- a/src/services/ramp-hooks.test.js +++ b/src/services/ramp-hooks.test.js @@ -395,3 +395,123 @@ describe('useActiveStructure', () => { }); }); + +describe('useAnnotations', () => { + // not a real ref because react throws warning if we use outside a component + const resultRef = { current: null }; + const renderHook = (props = {}) => { + const UIComponent = () => { + const results = hooks.useAnnotations({ + ...props + }); + useEffect(() => { + resultRef.current = results; + }, [results]); + return ( +
    + ); + }; + return UIComponent; + }; + + let props = { + canvasId: 'https://example.com/manifest/lunchroom_manners/canvas/1', + displayedAnnotations: [ + { + id: 'https://example.com/manifest/lunchroom_manners/canvas/1/annotation-page/1/annotation/1', + canvasId: 'https://example.com/manifest/lunchroom_manners/canvas/1', + motivation: ['supplementing'], + time: { start: 7, end: 44 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Men singing' }] + }, + { + id: 'https://example.com/manifest/lunchroom_manners/canvas/1/annotation-page/1/annotation/2', + canvasId: 'https://example.com/manifest/lunchroom_manners/canvas/1', + motivation: ['supplementing'], + time: { start: 24.32, end: 25.33 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'Subjects: Singing' }] + }, + { + id: 'https://example.com/manifest/lunchroom_manners/canvas/1/annotation-page/1/annotation/3', + canvasId: 'https://example.com/manifest/lunchroom_manners/canvas/1', + motivation: ['supplementing'], + time: { start: 28.43, end: 29.35 }, + value: [{ format: 'text/plain', purpose: ['supplementing'], value: 'The Yale Glee Club singing "Mother of Men"' }] + }, + ] + }; + + test('when player\'s currentTime is not within annotation\'s start and end times returns inPlayerRange=false ', () => { + const UIComponent = renderHook({ + ...props, + startTime: 7, + endTime: 44, + currentTime: 5, + }); + const CustomComponent = withManifestAndPlayerProvider(UIComponent, { + initialManifestState: { + ...manifestState(lunchroomManners) + }, + initialPlayerState: {}, + }); + render(); + + expect(resultRef.current.inPlayerRange).toBeFalsy(); + }); + + describe('when player\'s currentTime is within annotation\'s start and end times', () => { + test('without overlapping annotations returns inPlayerRange = true', () => { + const UIComponent = renderHook({ + ...props, + startTime: 7, + endTime: 44, + currentTime: 10, + }); + const CustomComponent = withManifestAndPlayerProvider(UIComponent, { + initialManifestState: { + ...manifestState(lunchroomManners) + }, + initialPlayerState: {}, + }); + render(); + + expect(resultRef.current.inPlayerRange).toBeTruthy(); + }); + + test('with overlapping annotations returns inPlayerRange = false', () => { + const UIComponent = renderHook({ + ...props, + startTime: 7, + endTime: 44, + currentTime: 24.35, + }); + const CustomComponent = withManifestAndPlayerProvider(UIComponent, { + initialManifestState: { + ...manifestState(lunchroomManners) + }, + initialPlayerState: {}, + }); + render(); + + expect(resultRef.current.inPlayerRange).toBeFalsy(); + }); + }); + + test('when player\'s currentTime is within annotation\'s start and end times returns inPlayerRange=true', () => { + const UIComponent = renderHook({ + ...props, + startTime: 28.43, + endTime: 29.35, + currentTime: 28.45, + }); + const CustomComponent = withManifestAndPlayerProvider(UIComponent, { + initialManifestState: { + ...manifestState(lunchroomManners) + }, + initialPlayerState: {}, + }); + render(); + + expect(resultRef.current.inPlayerRange).toBeTruthy(); + }); +});