An interactive, IIIF powered A/V player built with components
@@ -50,7 +50,7 @@ const App = ({ manifestURL }) => {
diff --git a/demo/app.scss b/demo/app.scss
index 63efed65..aed06a6a 100644
--- a/demo/app.scss
+++ b/demo/app.scss
@@ -35,7 +35,7 @@ div.iiif-player-demo {
flex: 1;
}
- .tab-nav label {
+ .tab-nav>label {
display: block;
box-sizing: border-box;
/* tab content must clear this */
@@ -48,7 +48,7 @@ div.iiif-player-demo {
color: black;
}
- .tab-nav label:hover {
+ .tab-nav>label:hover {
background: #d3d3d3;
border-radius: 0.5rem 0.5rem 0 0;
}
@@ -98,7 +98,7 @@ div.iiif-player-demo {
}
}
-.iiif-demo {
+.ramp-demo {
margin: auto;
width: 75%;
@@ -106,7 +106,7 @@ div.iiif-player-demo {
margin-bottom: 15px;
}
- input[type=url] {
+ .ramp-demo__manifest-input {
width: 80%;
padding: 11px;
border: 1px solid #ccc;
@@ -115,12 +115,12 @@ div.iiif-player-demo {
font-size: medium;
}
- label {
+ .ramp-demo__manifest-input-label {
padding: 12px 12px 12px 0;
display: inline-block;
}
- input[type=submit] {
+ .ramp-demo__manifest-submit {
background-color: #2a5459;
color: white;
padding: 12px 20px;
@@ -130,7 +130,7 @@ div.iiif-player-demo {
font-size: medium;
}
- input[type=submit]:hover {
+ .ramp-demo__manifest-submit:hover {
background-color: #80a590;
}
diff --git a/src/components/IIIFPlayerWrapper.js b/src/components/IIIFPlayerWrapper.js
index 137b18e6..1a9438e4 100644
--- a/src/components/IIIFPlayerWrapper.js
+++ b/src/components/IIIFPlayerWrapper.js
@@ -1,7 +1,8 @@
import React from 'react';
import { useManifestDispatch } from '../context/manifest-context';
import PropTypes from 'prop-types';
-import { parseAutoAdvance, getIsPlaylist } from '@Services/iiif-parser';
+import { parseAutoAdvance } from '@Services/iiif-parser';
+import { getAnnotationService, getIsPlaylist } from '@Services/playlist-parser';
export default function IIIFPlayerWrapper({
manifestUrl,
@@ -16,7 +17,12 @@ export default function IIIFPlayerWrapper({
if (manifest) {
dispatch({ manifest: manifest, type: 'updateManifest' });
} else {
- fetch(manifestUrl)
+ let requestOptions = {
+ // NOTE: try thin in Avalon
+ //credentials: 'include',
+ headers: { 'Avalon-Api-Key': '' },
+ };
+ fetch(manifestUrl, requestOptions)
.then((result) => result.json())
.then((data) => {
setManifest(data);
@@ -35,6 +41,9 @@ export default function IIIFPlayerWrapper({
const isPlaylist = getIsPlaylist(manifest);
dispatch({ isPlaylist: isPlaylist, type: 'setIsPlaylist' });
+
+ const annotationService = getAnnotationService(manifest);
+ dispatch({ annotationService: annotationService, type: 'setAnnotationService' });
}
}, [manifest]);
diff --git a/src/components/MarkersDisplay/MarkerUtils/CreateMarker.js b/src/components/MarkersDisplay/MarkerUtils/CreateMarker.js
new file mode 100644
index 00000000..b73769c4
--- /dev/null
+++ b/src/components/MarkersDisplay/MarkerUtils/CreateMarker.js
@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { createNewAnnotation, parseMarkerAnnotation } from '@Services/playlist-parser';
+import { validateTimeInput, timeToS, timeToHHmmss } from '@Services/utility-helpers';
+import { SaveIcon, CancelIcon } from './SVGIcons';
+
+const CreateMarker = ({ newMarkerEndpoint, canvasId, handleCreate, getCurrentTime }) => {
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [isValid, setIsValid] = React.useState(false);
+ const [saveError, setSaveError] = React.useState(false);
+ const [errorMessage, setErrorMessage] = React.useState('');
+ const [markerTime, setMarkerTime] = React.useState();
+
+ const handleAddMarker = () => {
+ const currentTime = timeToHHmmss(getCurrentTime(), true, true);
+ validateTime(currentTime);
+ setIsOpen(true);
+ };
+
+ const handleCreateSubmit = (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const formData = new FormData(form);
+ const { label, time } = Object.fromEntries(formData.entries());
+ const annotation = {
+ type: "Annotation",
+ motivation: "highlighting",
+ body: {
+ type: "TextualBody",
+ format: "text/html",
+ value: label,
+ },
+ target: `${canvasId}#t=${timeToS(time)}`
+ };
+
+ const requestOptions = {
+ method: 'POST',
+ /** NOTE: In avalon try this option */
+ headers: {
+ 'Accept': 'application/json',
+ 'Avalon-Api-Key': '',
+ },
+ body: JSON.stringify(annotation)
+ };
+ fetch(newMarkerEndpoint, requestOptions)
+ .then((response) => {
+ if (response.status != 201) {
+ throw new Error();
+ } else {
+ return response.json();
+ }
+ }).then((json) => {
+ const anno = createNewAnnotation(json);
+ const newMarker = parseMarkerAnnotation(anno);
+ if (newMarker) {
+ handleCreate(newMarker);
+ }
+ setIsOpen(false);
+ })
+ .catch((e) => {
+ console.error('Failed to create annotation; ', e);
+ setSaveError(true);
+ setErrorMessage('Marker creation failed.');
+ });
+ };
+
+ const handleCreateCancel = () => {
+ setIsOpen(false);
+ setIsValid(false);
+ setErrorMessage('');
+ setSaveError(false);
+ };
+
+ const validateTime = (value) => {
+ setMarkerTime(value);
+ let isValid = validateTimeInput(value);
+ setIsValid(isValid);
+ };
+
+ return (
+
+
+ {isOpen &&
+ (
)
+ }
+
+ );
+};
+
+CreateMarker.propTypes = {
+ newMarkerEndpoint: PropTypes.string.isRequired,
+ canvasId: PropTypes.string,
+ handleCreate: PropTypes.func.isRequired,
+ getCurrentTime: PropTypes.func.isRequired,
+};
+
+export default CreateMarker;
diff --git a/src/components/MarkersDisplay/MarkerUtils/CreateMarker.test.js b/src/components/MarkersDisplay/MarkerUtils/CreateMarker.test.js
new file mode 100644
index 00000000..1addfae7
--- /dev/null
+++ b/src/components/MarkersDisplay/MarkerUtils/CreateMarker.test.js
@@ -0,0 +1,91 @@
+import React from 'react';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import CreateMarker from './CreateMarker';
+
+describe('CreateMarker component', () => {
+ const handleCreateMock = jest.fn();
+ const getCurrentTimeMock = jest.fn(() => { return 44.3; });
+ beforeEach(() => {
+ render(
);
+ });
+
+ test('renders successfully', () => {
+ expect(screen.queryByTestId('create-new-marker-button')).toBeInTheDocument();
+ expect(screen.queryByTestId('create-new-marker-form')).not.toBeInTheDocument();
+ });
+
+ test('add new marker button click opens form', () => {
+ fireEvent.click(screen.getByTestId('create-new-marker-button'));
+ expect(screen.queryByTestId('create-new-marker-form')).toBeInTheDocument();
+ expect(screen.getByTestId('create-marker-title')).toBeInTheDocument();
+ expect(screen.getByTestId('create-marker-timestamp')).toBeInTheDocument();
+ });
+
+ test('form opens with empty title and current time of playhead', () => {
+ fireEvent.click(screen.getByTestId('create-new-marker-button'));
+ waitFor(() => {
+ expect(getCurrentTimeMock).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId('create-marker-title')).toHaveTextContent('');
+ expect(screen.getByTestId('create-marker-timestamp')).toHaveTextContent('00:00:44.300');
+ expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-valid');
+ });
+ });
+
+ test('validates time input and enable/disable save button', () => {
+ fireEvent.click(screen.getByTestId('create-new-marker-button'));
+ fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00' } });
+ expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-invalid');
+ expect(screen.getByTestId('edit-save-button')).toBeDisabled();
+
+ fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00:00' } });
+ expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-valid');
+ expect(screen.getByTestId('edit-save-button')).not.toBeDisabled();
+
+ fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00:00:' } });
+ expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-invalid');
+ expect(screen.getByTestId('edit-save-button')).toBeDisabled();
+
+ fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00:00:32.' } });
+ expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-invalid');
+ expect(screen.getByTestId('edit-save-button')).toBeDisabled();
+
+ fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00:00:32.543' } });
+ expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-valid');
+ expect(screen.getByTestId('edit-save-button')).not.toBeDisabled();
+ });
+
+ test('saves marker on save button click', async () => {
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ status: 201,
+ json: jest.fn(() => {
+ return {
+ "@context": "http://www.w3.org/ns/anno.jsonld",
+ "id": "http://example.com/marker/1",
+ "type": "Annotation",
+ "motivation": "highlighting",
+ "body": {
+ "type": "TextualBody",
+ "value": "Test Marker"
+ },
+ "target": "http://example.com/manifest/canvas/1#t=44.3"
+ };
+ })
+ });
+
+ fireEvent.click(screen.getByTestId('create-new-marker-button'));
+ fireEvent.change(screen.getByTestId('create-marker-title'), { target: { value: 'Test Marker' } });
+
+ expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-valid');
+ expect(screen.getByTestId('edit-save-button')).not.toBeDisabled();
+
+ fireEvent.click(screen.getByTestId('edit-save-button'));
+ await waitFor(() => {
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(handleCreateMock).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/src/components/MarkersDisplay/MarkerUtils/MarkerRow.js b/src/components/MarkersDisplay/MarkerUtils/MarkerRow.js
new file mode 100644
index 00000000..16f9e3e0
--- /dev/null
+++ b/src/components/MarkersDisplay/MarkerUtils/MarkerRow.js
@@ -0,0 +1,294 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { CancelIcon, EditIcon, DeleteIcon, SaveIcon } from './SVGIcons';
+import { validateTimeInput, timeToS } from '@Services/utility-helpers';
+
+const MarkerRow = ({
+ marker,
+ handleSubmit,
+ handleMarkerClick,
+ handleDelete,
+ hasAnnotationService,
+ isEditing,
+ toggleIsEditing
+}) => {
+ const [editing, setEditing] = React.useState(false);
+ const [isValid, setIsValid] = React.useState(true);
+ const [tempMarker, setTempMarker] = React.useState();
+ const [deleting, setDeleting] = React.useState(false);
+ const [saveError, setSaveError] = React.useState(false);
+ const [errorMessage, setErrorMessage] = React.useState('');
+
+ // Remove all subscriptions on unmount
+ React.useEffect(() => {
+ return {};
+ }, []);
+
+ React.useEffect(() => {
+ setMarkerLabel(marker.value);
+ setMarkerTime(marker.timeStr);
+ }, [marker]);
+
+ let markerLabelRef = React.useRef(marker.value);
+ const setMarkerLabel = (label) => {
+ markerLabelRef.current = label;
+ };
+
+ let markerOffsetRef = React.useRef(timeToS(marker.timeStr));
+ let markerTimeRef = React.useRef(marker.timeStr);
+ const setMarkerTime = (time) => {
+ markerTimeRef.current = time;
+ markerOffsetRef.current = timeToS(time);
+ };
+
+ const handleEdit = () => {
+ setTempMarker({ time: markerTimeRef.current, label: markerLabelRef.current });
+ setEditing(true);
+ toggleIsEditing(true);
+ };
+
+ // Reset old information of the marker when edit action is cancelled
+ const handleCancel = () => {
+ setMarkerTime(tempMarker.time);
+ setMarkerLabel(tempMarker.label);
+ setTempMarker({});
+ resetError();
+ cancelAction();
+ };
+
+ // Submit edited information of the current marker
+ const handleEditSubmit = () => {
+ const annotation = {
+ type: "Annotation",
+ motivation: "highlighting",
+ body: {
+ type: "TextualBody",
+ format: "text/html",
+ value: markerLabelRef.current,
+ },
+ id: marker.id,
+ target: `${marker.canvasId}#t=${timeToS(markerTimeRef.current)}`
+ };
+ const requestOptions = {
+ method: 'PUT',
+ /** NOTE: In avalon try this option */
+ // credentials: 'same-origin',
+ headers: {
+ 'Accept': 'application/json',
+ 'Avalon-Api-Key': '',
+ },
+ body: JSON.stringify(annotation)
+ };
+ fetch(marker.id, requestOptions)
+ .then((response) => {
+ if (response.status != 201) {
+ throw new Error();
+ } else {
+ handleSubmit(markerLabelRef.current, markerTimeRef.current, marker.id);
+ resetError();
+ cancelAction();
+ }
+ })
+ .catch((e) => {
+ console.error('Failed to update annotation; ', e);
+ setSaveError(true);
+ setErrorMessage('Marker update failed');
+ });
+ };
+
+ // Validate timestamps when typing
+ const validateTime = (value) => {
+ let isValid = validateTimeInput(value);
+ setIsValid(isValid);
+ setMarkerTime(value);
+ };
+
+ // Toggle delete confirmation
+ const toggleDelete = () => {
+ setDeleting(true);
+ toggleIsEditing(true);
+ };
+
+ // Submit delete action
+ const submitDelete = () => {
+ const requestOptions = {
+ method: 'DELETE',
+ /** NOTE: In avalon try this option */
+ // credentials: 'same-origin',
+ headers: {
+ 'Accept': 'application/json',
+ 'Avalon-Api-Key': '',
+ }
+ };
+ // API call for DELETE
+ fetch(marker.id, requestOptions)
+ .then((response) => {
+ if (response.status != 200) {
+ throw new Error();
+ } else {
+ handleDelete(marker.id);
+ resetError();
+ cancelAction();
+ }
+ })
+ .catch((e) => {
+ console.error('Failed to delete annotation; ', e);
+ cancelAction();
+ setSaveError(true);
+ setErrorMessage('Marker delete failed.');
+ setTimeout(() => {
+ resetError();
+ }, 1500);
+ });
+ };
+
+ const resetError = () => {
+ setSaveError(false);
+ setErrorMessage('');
+ };
+
+ // Reset edit state when edit/delete actions are finished
+ const cancelAction = () => {
+ setDeleting(false);
+ setEditing(false);
+ toggleIsEditing(false);
+ };
+
+ if (editing) {
+ return (
+
+
+ setMarkerLabel(e.target.value)}
+ name="label" />
+ |
+
+ validateTime(e.target.value)}
+ name="time" />
+ |
+
+
+ {
+ saveError &&
+
+ {errorMessage}
+
+ }
+
+
+
+ |
+
+ );
+ } else if (deleting) {
+ return (
+
+
+ handleMarkerClick(e)}
+ data-offset={markerOffsetRef.current}>
+ {markerLabelRef.current}
+ |
+ {markerTimeRef.current} |
+
+
+ Are you sure?
+
+
+
+ |
+
+ );
+ } else {
+ return (
+
+
+ handleMarkerClick(e)}
+ data-offset={markerOffsetRef.current}>
+ {markerLabelRef.current}
+ |
+ {markerTimeRef.current} |
+ {hasAnnotationService &&
+
+
+ {
+ saveError &&
+
+ {errorMessage}
+
+ }
+
+
+
+ | }
+
+ );
+ }
+};
+
+MarkerRow.propTypes = {
+ marker: PropTypes.object.isRequired,
+ handleSubmit: PropTypes.func.isRequired,
+ handleMarkerClick: PropTypes.func.isRequired,
+ handleDelete: PropTypes.func.isRequired,
+ hasAnnotationService: PropTypes.bool.isRequired,
+ isEditing: PropTypes.bool.isRequired,
+ toggleIsEditing: PropTypes.func.isRequired,
+};
+
+export default MarkerRow;
diff --git a/src/components/MarkersDisplay/MarkerUtils/SVGIcons.js b/src/components/MarkersDisplay/MarkerUtils/SVGIcons.js
new file mode 100644
index 00000000..9b8ec04c
--- /dev/null
+++ b/src/components/MarkersDisplay/MarkerUtils/SVGIcons.js
@@ -0,0 +1,82 @@
+import React from 'react';
+
+// SVG icons for the edit buttons
+export const EditIcon = () => {
+ return (
+
+ );
+};
+
+export const DeleteIcon = () => {
+ return (
+
+ );
+};
+
+export const SaveIcon = () => {
+ return (
+
+ );
+};
+
+export const CancelIcon = () => {
+ return (
+
+ );
+};
diff --git a/src/components/MarkersDisplay/MarkersDisplay.js b/src/components/MarkersDisplay/MarkersDisplay.js
index 0a087cc9..61caba0b 100644
--- a/src/components/MarkersDisplay/MarkersDisplay.js
+++ b/src/components/MarkersDisplay/MarkersDisplay.js
@@ -2,114 +2,51 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useManifestDispatch, useManifestState } from '../../context/manifest-context';
import { usePlayerState } from '../../context/player-context';
-import { parsePlaylistAnnotations } from '@Services/iiif-parser';
+import { parsePlaylistAnnotations } from '@Services/playlist-parser';
+import { canvasesInManifest } from '@Services/iiif-parser';
import { timeToS } from '@Services/utility-helpers';
+import CreateMarker from './MarkerUtils/CreateMarker';
+import MarkerRow from './MarkerUtils/MarkerRow';
import './MarkersDisplay.scss';
-// SVG icons for the edit buttons
-const EditIcon = () => {
- return (
-
- );
-};
-
-const DeleteIcon = () => {
- return (
-
- );
-};
-
-const SaveIcon = () => {
- return (
-
- );
-};
-
-const CancelIcon = () => {
- return (
-
- );
-};
-
const MarkersDisplay = ({ showHeading = true, headingText = 'Markers' }) => {
const { manifest, canvasIndex, playlist } = useManifestState();
const { player } = usePlayerState();
const manifestDispatch = useManifestDispatch();
- const { isEditing } = playlist;
+ const { isEditing, hasAnnotationService, annotationServiceId } = playlist;
- const [playlistMarkers, setPlaylistMarkers] = React.useState([]);
const [errorMsg, setErrorMsg] = React.useState();
+ const canvasIdRef = React.useRef();
+
+ let playlistMarkersRef = React.useRef([]);
+ const setPlaylistMarkers = (list) => {
+ playlistMarkersRef.current = list;
+ };
React.useEffect(() => {
if (manifest) {
- const { markers, error } = parsePlaylistAnnotations(manifest, canvasIndex);
- setPlaylistMarkers(markers);
+ const playlistMarkers = parsePlaylistAnnotations(manifest);
+ manifestDispatch({ markers: playlistMarkers, type: 'setPlaylistMarkers' });
+ canvasIdRef.current = canvasesInManifest(manifest)[canvasIndex].canvasId;
+ }
+ }, [manifest]);
+
+ React.useEffect(() => {
+ if (playlist.markers?.length > 0) {
+ let { canvasMarkers, error } = playlist.markers.filter((m) => m.canvasIndex === canvasIndex)[0];
+ setPlaylistMarkers(canvasMarkers);
setErrorMsg(error);
- manifestDispatch({ markers, type: 'setPlaylistMarkers' });
}
- }, [manifest, canvasIndex]);
- const handleSubmit = (label, time, id, canvasId) => {
- /* TODO:: Update the state once the API call is successful */
- // Update markers in state for displaying in the player UI
- let editedMarkers = playlistMarkers.map(m => {
+ if (manifest) {
+ canvasIdRef.current = canvasesInManifest(manifest)[canvasIndex].canvasId;
+ }
+ }, [canvasIndex, playlist.markers]);
+
+ const handleSubmit = (label, time, id) => {
+ // Re-construct markers list for displaying in the player UI
+ let editedMarkers = playlistMarkersRef.current.map(m => {
if (m.id === id) {
m.value = label;
m.timeStr = time;
@@ -118,251 +55,93 @@ const MarkersDisplay = ({ showHeading = true, headingText = 'Markers' }) => {
return m;
});
setPlaylistMarkers(editedMarkers);
- manifestDispatch({ markers: editedMarkers, type: 'setPlaylistMarkers' });
-
- // Call the annotation service to update the marker in the back-end
- const annotation = {
- type: "Annotation",
- motivation: "highlighting",
- body: {
- type: "TextualBody",
- format: "text/html",
- value: label,
- },
- id: id,
- target: `${canvasId}#t=${timeToS(time)}`
- };
- const requestOptions = {
- method: 'PUT',
- credentials: "include",
- body: JSON.stringify(annotation)
- };
- // fetch(id, requestOptions)
- // .then((response) => {
- // console.log(response);
- // /* Update state */
- // });
- // return;
+ manifestDispatch({ updatedMarkers: editedMarkers, type: 'setPlaylistMarkers' });
};
const handleDelete = (id) => {
- /* TODO:: Udate the state once the API call is successful */
+ let remainingMarkers = playlistMarkersRef.current.filter(m => m.id != id);
// Update markers in state for displaying in the player UI
- let remainingMarkers = playlistMarkers.filter(m => m.id != id);
setPlaylistMarkers(remainingMarkers);
- manifestDispatch({ markers: remainingMarkers, type: 'setPlaylistMarkers' });
-
- console.log('deleting marker: ', id);
- // API call for DELETE
- // fetch(id, requestOptions)
- // .then((response) => {
- // console.log(response);
- // /* Update state */
- // });
- // return;
+ manifestDispatch({ updatedMarkers: remainingMarkers, type: 'setPlaylistMarkers' });
};
const handleMarkerClick = (e) => {
+ e.preventDefault();
const currentTime = parseFloat(e.target.dataset['offset']);
player.currentTime(currentTime);
};
- if (playlistMarkers.length > 0) {
- return (
-
- {showHeading && (
-
-
{headingText}
-
- )}
-
+ const handleCreate = (newMarker) => {
+ setPlaylistMarkers([...playlistMarkersRef.current, newMarker]);
+ manifestDispatch({ updatedMarkers: playlistMarkersRef.current, type: 'setPlaylistMarkers' });
+ };
+
+ const toggleIsEditing = (flag) => {
+ manifestDispatch({ isEditing: flag, type: 'setIsEditing' });
+ };
+
+ /** Get the current time of the playhead */
+ const getCurrentTime = () => {
+ if (player) {
+ return player.currentTime();
+ } else {
+ return 0;
+ }
+ };
+
+ return (
+
+ {showHeading && (
+
+
{headingText}
+
+ )}
+ {hasAnnotationService && (
+
+ )}
+ {playlistMarkersRef.current.length > 0 && (
+
Name |
Time |
- Actions |
+ {hasAnnotationService && Actions | }
- {playlistMarkers.map((marker, index) => (
+ {playlistMarkersRef.current.map((marker, index) => (
+ hasAnnotationService={hasAnnotationService}
+ isEditing={isEditing}
+ toggleIsEditing={toggleIsEditing} />
))}
-
- );
- } else {
- return ;
- }
-};
-
-const MarkerRow = ({ marker, handleSubmit, handleMarkerClick, handleDelete, isEditing }) => {
- const manifestDispatch = useManifestDispatch();
- const [editing, setEditing] = React.useState(false);
- const [markerLabel, setMarkerLabel] = React.useState(marker.value);
- const [markerTime, setMarkerTime] = React.useState(marker.timeStr);
- const [markerOffset, setMarkerOffset] = React.useState(marker.time);
- const [isValid, setIsValid] = React.useState(true);
- const [tempMarker, setTempMarker] = React.useState();
- const [deleting, setDeleting] = React.useState(false);
-
- const handleEdit = () => {
- setTempMarker({ time: markerTime, label: markerLabel });
- setEditing(true);
- manifestDispatch({ isEditing: true, type: 'setIsEditing' });
- };
-
- // Reset old information of the marker when edit action is cancelled
- const handleCancel = () => {
- setMarkerTime(tempMarker.time);
- setMarkerLabel(tempMarker.label);
- setTempMarker({});
- cancelAction();
- };
-
- // Submit edited information of the current marker
- const handleEditSubmit = () => {
- setMarkerOffset(timeToS(markerTime));
- handleSubmit(markerLabel, markerTime, marker.id, marker.canvasId);
- cancelAction();
- };
-
- // Validate timestamps when typing
- const validateTimeInput = (value) => {
- const timeRegex = /^(([0-1][0-9])|([2][0-3])):([0-5][0-9])(:[0-5][0-9](?:[.]\d{1,3})?)?$/;
- setIsValid(timeRegex.test(value));
- setMarkerTime(value);
- };
-
- // Toggle delete confirmation
- const toggleDelete = () => {
- setDeleting(true);
- manifestDispatch({ isEditing: true, type: 'setIsEditing' });
- };
-
- // Submit delete action
- const submitDelete = () => {
- handleDelete(marker.id);
- cancelAction();
- };
-
- // Reset edit state when edit/delete actions are finished
- const cancelAction = () => {
- setDeleting(false);
- setEditing(false);
- manifestDispatch({ isEditing: false, type: 'setIsEditing' });
- };
-
- if (editing) {
- return (
-
-
- setMarkerLabel(e.target.value)}
- name="label" />
- |
-
- validateTimeInput(e.target.value)}
- name="time" />
- |
-
-
-
- |
-
- );
- } else if (deleting) {
- return (
-
-
- handleMarkerClick(e)} data-offset={markerOffset}>
- {markerLabel}
- |
- {markerTime} |
-
-
- Are you sure?
-
-
-
- |
-
- );
- } else {
- return (
-
-
- handleMarkerClick(e)} data-offset={markerOffset}>
- {markerLabel}
- |
- {markerTime} |
-
-
-
- |
-
- );
- }
-
+ )}
+ {playlistMarkersRef.current.length == 0 && (
+
+ )}
+
+ );
};
MarkersDisplay.propTypes = {
diff --git a/src/components/MarkersDisplay/MarkersDisplay.scss b/src/components/MarkersDisplay/MarkersDisplay.scss
index 20ae3186..f46612a2 100644
--- a/src/components/MarkersDisplay/MarkersDisplay.scss
+++ b/src/components/MarkersDisplay/MarkersDisplay.scss
@@ -1,10 +1,10 @@
@import '../../styles/vars';
.ramp--markers-display {
- min-width: 30rem;
+ min-width: inherit;
padding: 1rem;
- .ramp--markers-display-title {
+ .ramp--markers-display__title {
border: 0.05rem solid $primaryLight;
border-radius: 0.25rem 0.25rem 0 0;
margin-bottom: 1rem;
@@ -20,29 +20,44 @@
table {
font-family: arial, sans-serif;
- border-collapse: collapse;
width: 100%;
+ border-collapse: collapse;
*:disabled {
cursor: not-allowed;
opacity: 0.8;
}
- th:nth-child(3) {
+ td:nth-child(3) {
width: 40%;
}
- td,
th {
- border: 1px solid #dddddd;
+ border: 1px solid $primaryLightest;
+ padding: 0.5rem;
+ }
+
+ td {
+ border: 1px solid $primaryLightest;
text-align: left;
- padding: 8px;
+ padding: 0.5rem;
font-weight: normal;
}
+
+ input.ramp--markers-display__edit-marker {
+ width: 100%;
+ padding: 0.5rem 0.25rem;
+ display: inline-block;
+ border: 1px solid #ccc;
+ border-radius: 0.2rem;
+ box-sizing: border-box;
+ font-size: inherit;
+ }
}
- .delete-confirmation {
+ .marker-actions {
display: flex;
+ justify-content: flex-end;
p {
margin: 0;
@@ -50,24 +65,14 @@
}
}
- input[type=text] {
- width: 100%;
- padding: 0.5rem 0.25rem;
- display: inline-block;
- border: 1px solid #ccc;
- border-radius: 0.2rem;
- box-sizing: border-box;
- font-size: inherit;
- }
-
.time-invalid {
outline: none;
- border-color: red;
- box-shadow: 0 0 10px red;
+ border-color: $danger;
+ box-shadow: 0 0 10px $danger;
}
- .ramp--edit-button {
- background-color: #2a5459;
+ .ramp--markers-display__edit-button {
+ background-color: $primaryGreenDark;
color: white;
padding: 5px 10px;
border: none;
@@ -76,8 +81,8 @@
margin-left: 0.5rem;
}
- .ramp--edit-button-danger {
- background-color: #f44336;
+ .ramp--markers-display__edit-button-danger {
+ background-color: $danger;
color: white;
padding: 5px 10px;
border: none;
@@ -85,9 +90,45 @@
cursor: pointer;
margin-left: 0.5rem;
}
+
+ .ramp--markers-display__error-message {
+ color: $danger;
+ font-size: small;
+ margin: auto;
+ }
}
-.ramp--markers-empty {
+.ramp--markers-display__markers-empty {
font-size: medium;
padding: 2em;
}
+
+// Styling for new marker form
+.ramp-markers-display__new-marker {
+ margin-bottom: 1rem;
+}
+
+.ramp--markers-display__new-marker-form {
+ border: 1px solid $primaryLight;
+ padding: 0.5rem;
+ border-radius: 0.25rem;
+ margin: 1rem 0;
+ font-size: 0.85rem;
+ font-weight: bold;
+
+ table.create-marker-form-table {
+ border: none;
+ }
+
+ input.ramp--markers-display__create-marker {
+ width: 80%;
+ vertical-align: middle;
+ padding: 0.5rem 0.25rem;
+ background-color: #fff;
+ border: 1px solid #ccc;
+ border-radius: 0.2rem;
+ box-sizing: border-box;
+ margin-left: 0.5rem;
+ font-size: inherit;
+ }
+}
diff --git a/src/components/MarkersDisplay/MarkersDisplay.test.js b/src/components/MarkersDisplay/MarkersDisplay.test.js
index 6f854502..d5154c61 100644
--- a/src/components/MarkersDisplay/MarkersDisplay.test.js
+++ b/src/components/MarkersDisplay/MarkersDisplay.test.js
@@ -1,5 +1,5 @@
import React from 'react';
-import { fireEvent, render, screen } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import MarkersDisplay from './MarkersDisplay';
import manifest from '@TestData/playlist';
import manifestWoMarkers from '@TestData/lunchroom-manners';
@@ -12,7 +12,11 @@ describe('MarkersDisplay component', () => {
initialManifestState: {
manifest,
canvasIndex: 1,
- playlist: {}
+ playlist: {
+ hasAnnotationService: true,
+ isEditing: false,
+ annotationServiceId: 'http://example.com/marker'
+ }
},
initialPlayerState: {},
});
@@ -21,6 +25,7 @@ describe('MarkersDisplay component', () => {
test('renders successfully', () => {
expect(screen.queryByTestId('markers-display')).toBeInTheDocument();
+ expect(screen.queryByTestId('markers-display-table')).toBeInTheDocument();
expect(screen.queryByTestId('markers-empty')).not.toBeInTheDocument();
});
@@ -40,6 +45,19 @@ describe('MarkersDisplay component', () => {
let firstEditButton, secondEditButton,
firstDeleteButton, secondDeleteButton;
beforeEach(() => {
+ const MarkersDisplayWrapped = withManifestAndPlayerProvider(MarkersDisplay, {
+ initialManifestState: {
+ manifest,
+ canvasIndex: 1,
+ playlist: {
+ hasAnnotationService: true,
+ isEditing: false,
+ annotationServiceId: 'http://example.com/marker'
+ }
+ },
+ initialPlayerState: {},
+ });
+ render();
firstEditButton = screen.queryAllByTestId('edit-button')[0];
secondEditButton = screen.queryAllByTestId('edit-button')[1];
firstDeleteButton = screen.queryAllByTestId('delete-button')[0];
@@ -83,6 +101,9 @@ describe('MarkersDisplay component', () => {
});
test('saving updates the markers table', () => {
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ status: 201,
+ });
fireEvent.click(firstEditButton);
const labelInput = screen.getByTestId('edit-label');
expect(labelInput).toHaveDisplayValue('Marker 1');
@@ -90,16 +111,25 @@ describe('MarkersDisplay component', () => {
fireEvent.change(labelInput, { target: { value: 'Test Marker' } });
fireEvent.click(screen.getByTestId('edit-save-button'));
- expect(screen.queryByText('Test Marker')).toBeInTheDocument();
+ waitFor(() => {
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(screen.queryByText('Test Marker')).toBeInTheDocument();
+ });
});
test('delete action removes the marker from table', () => {
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ status: 200,
+ });
fireEvent.click(secondDeleteButton);
expect(screen.queryByTestId('delete-confirm-button')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('delete-confirm-button'));
- expect(screen.queryByText('Marker 2')).not.toBeInTheDocument();
+ waitFor(() => {
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(screen.queryByText('Marker 2')).not.toBeInTheDocument();
+ });
});
});
});
@@ -115,11 +145,12 @@ describe('MarkersDisplay component', () => {
initialPlayerState: {},
});
render();
- expect(screen.queryByTestId('markers-display')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('markers-display')).toBeInTheDocument();
+ expect(screen.queryByTestId('markers-display-table')).not.toBeInTheDocument();
expect(screen.queryByTestId('markers-empty')).toBeInTheDocument();
- expect(screen.queryByText(
- 'No markers were found in the Canvas'
- )).toBeInTheDocument();
+ waitFor(() => {
+ expect(screen.queryByText('No markers were found in the Canvas')).toBeInTheDocument();
+ });
});
});
});
diff --git a/src/components/MediaPlayer/MediaPlayer.js b/src/components/MediaPlayer/MediaPlayer.js
index 790df0a3..b7382f8a 100644
--- a/src/components/MediaPlayer/MediaPlayer.js
+++ b/src/components/MediaPlayer/MediaPlayer.js
@@ -376,7 +376,6 @@ const MediaPlayer = ({ enableFileDownload = false, enablePIP = false }) => {
>
{
- let markersList = [];
- if (playlistMarkers?.length > 0) {
+ if (playlist.markers?.length > 0) {
+ const playlistMarkers = playlist.markers
+ .filter((m) => m.canvasIndex === canvasIndex)[0].canvasMarkers;
+ let markersList = [];
playlistMarkers.map((m) => {
markersList.push({ time: parseFloat(m.time), text: m.value });
});
- }
- if (player && player.markers && isReady) {
- // Clear existing markers when updating the markers
- player.markers.removeAll();
- player.markers.add(markersList);
+ if (player && player.markers && isReady) {
+ // Clear existing markers when updating the markers
+ player.markers.removeAll();
+ player.markers.add(markersList);
+ }
}
- }, [player, isReady, playlistMarkers]);
+
+ }, [player, isReady, playlist.markers]);
/**
* Switch canvas when using structure navigation / the media file ends
@@ -319,7 +322,7 @@ function VideoJSPlayer({
if (!player || !currentPlayer) {
return;
}
- if (currentNavItem !== null && isReady) {
+ if (currentNavItem !== null && isReady && !isPlaylist) {
// Mark current time fragment
if (player.markers) {
if (!isPlaylist) {
@@ -534,7 +537,6 @@ function VideoJSPlayer({
VideoJSPlayer.propTypes = {
isVideo: PropTypes.bool,
- playlistMarkers: PropTypes.array,
isPlaylist: PropTypes.bool,
switchPlayer: PropTypes.func,
handleIsEnded: PropTypes.func,
diff --git a/src/context/manifest-context.js b/src/context/manifest-context.js
index 98cdc08d..d6302b71 100644
--- a/src/context/manifest-context.js
+++ b/src/context/manifest-context.js
@@ -18,9 +18,11 @@ const defaultState = {
startTime: 0,
autoAdvance: false,
playlist: {
- markers: [],
+ markers: [], // [{ canvasIndex: Number, canvasMarkers: Array, error: String }]
isEditing: false,
- isPlaylist: false
+ isPlaylist: false,
+ hasAnnotationService: false,
+ annotationServiceId: '',
}
};
@@ -81,13 +83,32 @@ function manifestReducer(state = defaultState, action) {
};
}
case 'setPlaylistMarkers': {
- return {
- ...state,
- playlist: {
- ...state.playlist,
- markers: action.markers,
- }
- };
+ // Set a new set of markers for the canvases in the Manifest
+ if (action.markers) {
+ return {
+ ...state,
+ playlist: {
+ ...state.playlist,
+ markers: action.markers,
+ }
+ };
+ }
+ // Update the existing markers for the current canvas on CRUD ops
+ if (action.updatedMarkers) {
+ return {
+ ...state,
+ playlist: {
+ ...state.playlist,
+ markers: state.playlist.markers.map((m) => {
+ if (m.canvasIndex === state.canvasIndex) {
+ m.canvasMarkers = action.updatedMarkers;
+ }
+ return m;
+ })
+ }
+ };
+
+ }
}
case 'setIsEditing': {
return {
@@ -113,6 +134,16 @@ function manifestReducer(state = defaultState, action) {
canvasIsEmpty: action.isEmpty,
};
}
+ case 'setAnnotationService': {
+ return {
+ ...state,
+ playlist: {
+ ...state.playlist,
+ annotationServiceId: action.annotationService,
+ hasAnnotationService: action.annotationService ? true : false,
+ }
+ };
+ }
default: {
throw new Error(`Unhandled action type: ${action.type}`);
}
diff --git a/src/services/iiif-parser.js b/src/services/iiif-parser.js
index 8f6eaa3f..e2641dab 100644
--- a/src/services/iiif-parser.js
+++ b/src/services/iiif-parser.js
@@ -523,57 +523,3 @@ export function parseAutoAdvance(manifest) {
const autoAdvanceBehavior = parseManifest(manifest).getProperty("behavior")?.includes("auto-advance");
return (autoAdvanceBehavior === undefined) ? false : autoAdvanceBehavior;
}
-
-/**
- * Parses the manifest to identify whether it is a playlist manifest
- * or not
- * @param {Object} manifest
- * @returns {Boolean}
- */
-export function getIsPlaylist(manifest) {
- try {
- const manifestTitle = manifest.label;
- let isPlaylist = getLabelValue(manifestTitle).includes('[Playlist]');
- return isPlaylist;
- } catch (err) {
- console.error('Cannot parse manfiest, ', err);
- return false;
- }
-}
-
-/**
- * Parse `highlighting` annotations with TextualBody type as markers
- * @param {Object} manifest
- * @param {Number} canvasIndex current canvas index
- * @returns {Array