Skip to content

Commit

Permalink
Annotations component (#757)
Browse files Browse the repository at this point in the history
* Annotation layer select component

* Display annotations, add prop to filter by motivation

* Add tag colors, seek on annotation click event, and fix display of linked annotations

* Fix failing tests in annotation-parser

* Code review: change docs, demo site active tab, and refactor function name
  • Loading branch information
Dananji authored Dec 23, 2024
1 parent ac213fb commit 86ef601
Show file tree
Hide file tree
Showing 14 changed files with 898 additions and 56 deletions.
6 changes: 3 additions & 3 deletions demo/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const App = ({ manifestURL }) => {
{ title: 'Details', ref: React.useRef(null) },
{ title: 'Transcripts', ref: React.useRef(null) },
{ title: 'Files', ref: React.useRef(null) },
{ title: 'Markers', ref: React.useRef(null) },
{ title: 'Annotations', ref: React.useRef(null) },
];

React.useEffect(() => {
Expand Down Expand Up @@ -88,7 +88,7 @@ const App = ({ manifestURL }) => {

/*Reference: https://accessible-react.eevis.codes/components/tabs */
const Tabs = ({ tabValues, manifestUrl }) => {
const [activeTab, setActiveTab] = React.useState(1);
const [activeTab, setActiveTab] = React.useState(0);

let tabs = [];

Expand Down Expand Up @@ -156,7 +156,7 @@ const Tabs = ({ tabValues, manifestUrl }) => {
<TabPanel id="filesTab" tabId="files" tabIndex={2} activeTab={activeTab}>
<SupplementalFiles showHeading={false} />
</TabPanel>
<TabPanel id="markersTab" tabId="markers" tabIndex={3} activeTab={activeTab}>
<TabPanel id="annotationsTab" tabId="annotations" tabIndex={3} activeTab={activeTab}>
<MarkersDisplay showHeading={false} />
</TabPanel>
</section>
Expand Down
155 changes: 155 additions & 0 deletions src/components/MarkersDisplay/Annotations/AnnotationLayerSelect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
parseExternalAnnotationPage,
parseExternalAnnotationResource
} from '@Services/annotations-parser';

const AnnotationLayerSelect = ({ annotationLayers = [], duration = 0, setDisplayedAnnotationLayers }) => {
const [selectedAnnotationLayers, setSelectedAnnotationLayers] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const [selectedAll, setSelectedAll] = useState(false);

useEffect(() => {
if (annotationLayers?.length > 0) {
// Sort annotation sets alphabetically
annotationLayers.sort((a, b) => a.label.localeCompare(b.label));
// Select the first annotation set on page load
findOrFetchandParseLinkedAnnotations(annotationLayers[0]);
}
}, [annotationLayers]);

const isSelected = (layer) => selectedAnnotationLayers.includes(layer.label);
const toggleDropdown = () => setIsOpen((prev) => !prev);

/**
* Event handler for the check-box for each annotation layer in the dropdown
* @param {Object} annotationLayer checked/unchecked layer
*/
const handleSelect = async (annotationLayer) => {
findOrFetchandParseLinkedAnnotations(annotationLayer);

// Uncheck and clear annotation layer in state
if (isSelected(annotationLayer)) clearSelection(annotationLayer);
};

/**
* Fetch linked annotations and parse its content only on first time selection
* of the annotation layer
* @param {Object} annotationLayer checked/unchecked layer
*/
const findOrFetchandParseLinkedAnnotations = async (annotationLayer) => {
let items = annotationLayer.items;
if (!isSelected(annotationLayer)) {
// Only fetch and parse AnnotationPage for the first time selection
if (annotationLayer.url && !annotationLayer.items) {
// Parse linked annotations as AnnotationPage json
if (!annotationLayer?.linkedResource) {
let parsedAnnotationPage = await parseExternalAnnotationPage(annotationLayer.url, duration);
items = parsedAnnotationPage?.length > 0 ? parsedAnnotationPage[0].items : [];
}
// Parse linked annotations of other types, e.g. WebVTT, SRT, plain text, etc.
else {
let annotations = await parseExternalAnnotationResource(annotationLayer);
items = annotations;
}
}
// Mark annotation layer as selected
makeSelection(annotationLayer, items);
}
};

/**
* Event handler for the checkbox for 'Show all Annotation layers' option
* Check/uncheck all Annotation layers as slected/not-selected
*/
const handleSelectAll = async () => {
const selectAllUpdated = !selectedAll;
setSelectedAll(selectAllUpdated);
if (selectAllUpdated) {
await Promise.all(
annotationLayers.map((annotationLayer) => {
findOrFetchandParseLinkedAnnotations(annotationLayer);
})
);
} else {
// Clear all selections
setSelectedAnnotationLayers([]);
setDisplayedAnnotationLayers([]);
}

// Close the dropdown
toggleDropdown();
};

/**
* Remove unchecked annotation and its label from state. This function updates
* as a wrapper for updating both state variables in one place to avoid inconsistencies
* @param {Object} annotationLayer selected annotation layer
*/
const clearSelection = (annotationLayer) => {
setSelectedAnnotationLayers((prev) => prev.filter((item) => item !== annotationLayer.label));
setDisplayedAnnotationLayers((prev) => prev.filter((a) => a.label != annotationLayer.label));
};

/**
* Add checked annotation and its label to state. This function updates
* as a wrapper for updating both state variables in one place to avoid inconsistencies
* @param {Object} annotationLayer selected annotation layer
* @param {Array} items list of timed annotations
*/
const makeSelection = (annotationLayer, items) => {
annotationLayer.items = items;
setSelectedAnnotationLayers((prev) => [...prev, annotationLayer.label]);
setDisplayedAnnotationLayers((prev) => [...prev, annotationLayer]);
};

return (
<div className="ramp--annotatations__multi-select">
<div className="ramp--annotations__multi-select-header" onClick={toggleDropdown}>
{selectedAnnotationLayers.length > 0
? `${selectedAnnotationLayers.length} of ${annotationLayers.length} layers selected`
: "Select Annotation layer(s)"}
<span className={`annotations-dropdown-arrow ${isOpen ? "open" : ""}`}></span>
</div>
{isOpen && (
<ul className="annotations-dropdown-menu">
{
// Only show select all option when there's more than one annotation layer
annotationLayers?.length > 1 &&
<li key="select-all" className="annotations-dropdown-item">
<label>
<input
type="checkbox"
checked={selectedAll}
onChange={handleSelectAll}
/>
Show all Annotation layers
</label>
</li>
}
{annotationLayers.map((annotationLayer, index) => (
<li key={`annotaion-layer-${index}`} className="annotations-dropdown-item">
<label>
<input
type="checkbox"
checked={isSelected(annotationLayer)}
onChange={() => handleSelect(annotationLayer)}
/>
{annotationLayer.label}
</label>
</li>
))}
</ul>
)}
</div>
);
};

AnnotationLayerSelect.propTypes = {
annotationLayers: PropTypes.array.isRequired,
duration: PropTypes.number.isRequired,
setDisplayedAnnotationLayers: PropTypes.func.isRequired
};

export default AnnotationLayerSelect;
121 changes: 121 additions & 0 deletions src/components/MarkersDisplay/Annotations/AnnotationRow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@

import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import { timeToHHmmss } from '@Services/utility-helpers';
import { useAnnotations, useMediaPlayer } from '@Services/ramp-hooks';

const AnnotationRow = ({ annotation, displayMotivations }) => {
const { id, canvasId, motivation, time, value } = annotation;
const { start, end } = time;

const { player } = useMediaPlayer();
const { isCurrentCanvas } = useAnnotations({ canvasId });

/**
* Display only the annotations with at least one of the specified motivations
* when the component is initialized.
* The default value of 'displayMotivations' is set to an empty array,
* in which case the component displays all annotations related to Canvas.
*/
const canDisplay = useMemo(() => {
return displayMotivations?.length > 0
? displayMotivations.some(m => motivation.includes(m))
: true;
}, [annotation]);

/**
* 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
*/
const handleOnClick = useCallback((e) => {
e.preventDefault();
if (isCurrentCanvas) {
const currentTime = start;
if (player) {
const { start, end } = player.targets[0];
switch (true) {
case currentTime >= start && currentTime <= end:
player.currentTime(currentTime);
break;
case currentTime < start:
player.currentTime(start);
break;
case currentTime > end:
player.currentTime(end);
break;
}
}
}
}, [annotation, player]);

// 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
const texts = value.filter(
(v) => v.purpose.includes('commenting') || v.purpose.includes('supplementing')
);

if (canDisplay) {
return (
<li
key={`li_${id}`}
onClick={handleOnClick}
data-testid="annotation-row"
className="ramp--annotations__annotation-row"
>
<div key={`row_${id}`} className="ramp--annotations__annotation-row-time-tags">
<div key={`times_${id}`} className="ramp--annotations__annotation-times">
{start != undefined && (
<span
className="ramp--annotations__annotation-start-time"
data-testid="annotation-start-time"
>
{timeToHHmmss(start, true)}
</span>
)}
{end != undefined && (
<span
className="ramp--annotations__annotation-end-time"
data-testid="annotation-end-time"
>
{` - ${timeToHHmmss(end, true)}`}
</span>
)}
</div>
<div key={`tags_${id}`} className="ramp--annotations__annotation-tags">
{tags?.length > 0 && tags.map((tag, index) => {
return (
<p
key={`tag_${index}`}
className="ramp--annotations__annotation-tag"
style={{ backgroundColor: tag.tagColor }}>
{tag.value}
</p>
);
})}
</div>
</div>
{texts?.length > 0 && texts.map((text, index) => {
return (
<p
key={`text_${index}`}
className="ramp--annotations__annotation-text"
dangerouslySetInnerHTML={{ __html: text.value }}>
</p>
);
})}
</li>
);
} else {
return null;
}
};

AnnotationRow.propTypes = {
annotation: PropTypes.object.isRequired,
displayMotivations: PropTypes.array.isRequired,
};

export default AnnotationRow;
91 changes: 91 additions & 0 deletions src/components/MarkersDisplay/Annotations/AnnotationsDisplay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import React, { useEffect, useMemo, 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';

const AnnotationsDisplay = ({ annotations, canvasIndex, duration, displayMotivations }) => {
const [canvasAnnotationLayers, setCanvasAnnotationLayers] = useState([]);
const [displayedAnnotationLayers, setDisplayedAnnotationLayers] = useState([]);

/**
* Filter and merge annotations parsed from either an AnnotationPage or a linked
* resource in Annotation objects within an AnnotationPage for selected annotation
* layers.
*/
const displayedAnnotations = useMemo(() => {
return displayedAnnotationLayers?.length > 0
? sortAnnotations(displayedAnnotationLayers.map((a) => a.items).flat())
: [];
}, [displayedAnnotationLayers]);

/**
* Check if the annotations related to the Canvas have motivation(s) specified
* by the user when the component is initialized.
* If none of the annotations in the Canvas has at least one the specified
* motivation(s), then a message is displayed to the user.
*/
const hasDisplayAnnotations = useMemo(() => {
if (displayedAnnotations?.length > 0 && displayedAnnotations[0] != undefined) {
const motivations = displayedAnnotations.map((a) => a.motivation);
return displayMotivations?.length > 0
? displayMotivations.some(m => motivations.includes(m))
: true;
}
}, [displayedAnnotations]);

/**
* Update annotation sets for the current Canvas
*/
useEffect(() => {
if (annotations?.length > 0) {
const { _, annotationSets } = annotations
.filter((a) => a.canvasIndex === canvasIndex)[0];
setCanvasAnnotationLayers(annotationSets);
}
}, [annotations, canvasIndex]);

if (canvasAnnotationLayers?.length > 0) {
return (
<div className="ramp--annotations__display"
data-testid="annotations-display">
<div className="ramp--annotations__select">
<label>Annotation layers: </label>
<AnnotationLayerSelect
annotationLayers={canvasAnnotationLayers}
duration={duration}
setDisplayedAnnotationLayers={setDisplayedAnnotationLayers}
/>
</div>
<div className="ramp--annotations__content" tabIndex={0}>
{hasDisplayAnnotations
? displayedAnnotations != undefined && displayedAnnotations?.length > 0 && (
<ul>
{displayedAnnotations.map((annotation, index) => {
return (
<AnnotationRow
key={index}
annotation={annotation}
displayMotivations={displayMotivations}
/>
);
})}
</ul>
)
: <p>{`No Annotations with ${displayMotivations.join('/')} motivation.`}</p>
}
</div>
</div>
);
}
};

AnnotationsDisplay.propTypes = {
annotations: PropTypes.array.isRequired,
canvasIndex: PropTypes.number.isRequired,
duration: PropTypes.number.isRequired,
displayMotivations: PropTypes.array.isRequired,
};

export default AnnotationsDisplay;
Loading

0 comments on commit 86ef601

Please sign in to comment.