-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
14 changed files
with
898 additions
and
56 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
155 changes: 155 additions & 0 deletions
155
src/components/MarkersDisplay/Annotations/AnnotationLayerSelect.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
121
src/components/MarkersDisplay/Annotations/AnnotationRow.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
91
src/components/MarkersDisplay/Annotations/AnnotationsDisplay.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.