Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Annotations component #757

Merged
merged 5 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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