diff --git a/__tests__/fixtures/version-3/text-pdf.json b/__tests__/fixtures/version-3/text-pdf.json new file mode 100644 index 0000000000..0f1f5ca1b9 --- /dev/null +++ b/__tests__/fixtures/version-3/text-pdf.json @@ -0,0 +1,35 @@ +{ + "@context": "http://iiif.io/api/presentation/3/context.json", + "id": "https://iiif.io/api/cookbook/recipe/0001-text-pdf/manifest.json", + "type": "Manifest", + "label": { + "en": [ + "Simplest Text Example 1" + ] + }, + "items": [ + { + "id": "https://iiif.io/api/cookbook/recipe/0001-text-pdf/canvas", + "type": "Canvas", + "items": [ + { + "id": "https://iiif.io/api/cookbook/recipe/0001-text-pdf/canvas/page", + "type": "AnnotationPage", + "items": [ + { + "id": "https://iiif.io/api/cookbook/recipe/0001-text-pdf/canvas/page/annotation", + "type": "Annotation", + "motivation": "painting", + "body": { + "id": "https://fixtures.iiif.io/other/UCLA/kabuki_ezukushi_rtl.pdf", + "type": "Text", + "format": "application/pdf" + }, + "target": "https://iiif.io/api/cookbook/recipe/0001-text-pdf/canvas/page" + } + ] + } + ] + } + ] +} diff --git a/__tests__/src/components/TextViewer.test.js b/__tests__/src/components/TextViewer.test.js new file mode 100644 index 0000000000..4dece398ca --- /dev/null +++ b/__tests__/src/components/TextViewer.test.js @@ -0,0 +1,47 @@ +import { render, screen } from '@tests/utils/test-utils'; +import { TextViewer } from '../../../src/components/TextViewer'; + +/** create wrapper */ +function createWrapper(props, suspenseFallback) { + return render( + , + ); +} + +describe('TextViewer', () => { + describe('render', () => { + it('textResources as source elements', () => { + createWrapper({ + textResources: [ + { getFormat: () => 'application/pdf', getType: () => 'Text', id: 1 }, + ], + windowId: 'a', + }, true); + const text = screen.getByTestId('text'); + expect(text.querySelector('source:nth-of-type(1)')).toHaveAttribute('type', 'application/pdf'); // eslint-disable-line testing-library/no-node-access + }); + it('passes through configurable options', () => { + createWrapper({ + textResources: [ + { getFormat: () => 'application/pdf', getType: () => 'Text', id: 1 }, + ], + windowId: 'a', + }, true); + expect(screen.getByTestId('text')).toHaveAttribute('crossOrigin', 'anonymous'); + }); + it('canvas navigation', () => { + createWrapper({ + textResources: [ + { getFormat: () => 'application/pdf', getType: () => 'Text', id: 1 }, + ], + windowId: 'a', + }, true); + const text = screen.getByTestId('text'); + expect(text.querySelector('.mirador-canvas-nav')).toBeDefined(); // eslint-disable-line testing-library/no-node-access + }); + }); +}); diff --git a/__tests__/src/lib/MiradorCanvas.test.js b/__tests__/src/lib/MiradorCanvas.test.js index 9917ebc697..e5d19e3b2e 100644 --- a/__tests__/src/lib/MiradorCanvas.test.js +++ b/__tests__/src/lib/MiradorCanvas.test.js @@ -7,6 +7,7 @@ import otherContentStringsFixture from '../../fixtures/version-2/BibliographicRe import fragmentFixture from '../../fixtures/version-2/hamilton.json'; import fragmentFixtureV3 from '../../fixtures/version-3/hamilton.json'; import audioFixture from '../../fixtures/version-3/0002-mvm-audio.json'; +import textFixture from '../../fixtures/version-3/text-pdf.json'; import videoFixture from '../../fixtures/version-3/0015-start.json'; import videoWithAnnoCaptions from '../../fixtures/version-3/video_with_annotation_captions.json'; @@ -133,4 +134,12 @@ describe('MiradorCanvas', () => { expect(instance.v3VttContent.length).toEqual(1); }); }); + describe('textResources', () => { + it('returns text', () => { + instance = new MiradorCanvas( + Utils.parseManifest(textFixture).getSequences()[0].getCanvases()[0], + ); + expect(instance.textResources.length).toEqual(1); + }); + }); }); diff --git a/__tests__/src/lib/resourceFilters.test.js b/__tests__/src/lib/resourceFilters.test.js new file mode 100644 index 0000000000..e27e568cc8 --- /dev/null +++ b/__tests__/src/lib/resourceFilters.test.js @@ -0,0 +1,31 @@ +import { Utils } from 'manifesto.js'; +import flattenDeep from 'lodash/flattenDeep'; +import manifestFixture019 from '../../fixtures/version-2/019.json'; +import { + filterByProfiles, filterByTypes, +} from '../../../src/lib/resourceFilters'; + +describe('resourceFilters', () => { + let canvas; + beforeEach(() => { + [canvas] = Utils.parseManifest(manifestFixture019).getSequences()[0].getCanvases(); + }); + describe('filterByProfiles', () => { + it('filters resources', () => { + const services = flattenDeep(canvas.resourceAnnotations.map((a) => a.getResource().getServices())); + expect(filterByProfiles(services, 'http://iiif.io/api/image/2/level2.json').map((s) => s.id)).toEqual([ + 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44', + ]); + expect(filterByProfiles(services, 'http://nonexistent.io/api/service.json').map((s) => s.id)).toEqual([]); + }); + }); + describe('filterByTypes', () => { + it('filters resources', () => { + const resources = flattenDeep(canvas.resourceAnnotations.map((a) => a.getResource())); + expect(filterByTypes(resources, 'dctypes:Image').map((r) => r.id)).toEqual([ + 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/full/0/default.jpg', + ]); + expect(filterByTypes(resources, 'Nonexistent').map((r) => r.id)).toEqual([]); + }); + }); +}); diff --git a/__tests__/src/selectors/canvases.test.js b/__tests__/src/selectors/canvases.test.js index 47db6784f2..88b1778acf 100644 --- a/__tests__/src/selectors/canvases.test.js +++ b/__tests__/src/selectors/canvases.test.js @@ -3,6 +3,7 @@ import manifestFixture019 from '../../fixtures/version-2/019.json'; import minimumRequired from '../../fixtures/version-2/minimumRequired.json'; import minimumRequired3 from '../../fixtures/version-3/minimumRequired.json'; import audioFixture from '../../fixtures/version-3/0002-mvm-audio.json'; +import textFixture from '../../fixtures/version-3/text-pdf.json'; import videoFixture from '../../fixtures/version-3/0015-start.json'; import videoWithAnnoCaptions from '../../fixtures/version-3/video_with_annotation_captions.json'; import settings from '../../../src/config/settings'; @@ -15,6 +16,7 @@ import { getCanvasLabel, selectInfoResponse, getVisibleCanvasNonTiledResources, + getVisibleCanvasTextResources, getVisibleCanvasVideoResources, getVisibleCanvasAudioResources, getVisibleCanvasCaptions, @@ -462,4 +464,26 @@ describe('getVisibleCanvasNonTiledResources', () => { expect(getVisibleCanvasAudioResources(state, { windowId: 'a' })[0].id).toBe('https://fixtures.iiif.io/audio/indiana/mahler-symphony-3/CD1/medium/128Kbps.mp4'); }); }); + + describe('getVisibleCanvasTextResources', () => { + it('returns canvases resources', () => { + const state = { + manifests: { + 'https://iiif.io/api/cookbook/recipe/0001-text-pdf/manifest.json': { + id: 'https://iiif.io/api/cookbook/recipe/0001-text-pdf/manifest.json', + json: textFixture, + }, + }, + windows: { + a: { + manifestId: 'https://iiif.io/api/cookbook/recipe/0001-text-pdf/manifest.json', + visibleCanvases: [ + 'https://iiif.io/api/cookbook/recipe/0001-text-pdf/canvas', + ], + }, + }, + }; + expect(getVisibleCanvasTextResources(state, { windowId: 'a' })[0].id).toBe('https://fixtures.iiif.io/other/UCLA/kabuki_ezukushi_rtl.pdf'); + }); + }); }); diff --git a/src/components/PrimaryWindow.js b/src/components/PrimaryWindow.js index c89fe1f4b7..a656f25c6d 100644 --- a/src/components/PrimaryWindow.js +++ b/src/components/PrimaryWindow.js @@ -12,6 +12,7 @@ const GalleryView = lazy(() => import('../containers/GalleryView')); const SelectCollection = lazy(() => import('../containers/SelectCollection')); const WindowViewer = lazy(() => import('../containers/WindowViewer')); const VideoViewer = lazy(() => import('../containers/VideoViewer')); +const TextViewer = lazy(() => import('../containers/TextViewer')); GalleryView.displayName = 'GalleryView'; SelectCollection.displayName = 'SelectCollection'; @@ -25,8 +26,8 @@ const Root = styled('div', { name: 'PrimaryWindow', slot: 'root' })(() => ({ /** */ const TypeSpecificViewer = ({ - audioResources = [], isCollection = false, - isFetching = false, videoResources = [], view = undefined, windowId, + audioResources = [], isCollection = false, isFetching = false, textResources = [], + videoResources = [], view = undefined, windowId, }) => { if (isCollection) { return ( @@ -57,6 +58,13 @@ const TypeSpecificViewer = ({ /> ); } + if (textResources.length > 0) { + return ( + + ); + } return ( ({ + alignItems: 'center', + display: 'flex', + width: '100%', +})); + +const StyledText = styled('div')(() => ({ + maxHeight: '100%', + width: '100%', +})); + +/** + * Simple divs with canvas navigation, which should mimic v3 fallthrough to WindowViewer + * with non-image resources and provide a target for plugin overrides with minimal disruption. + */ +export function TextViewer({ textOptions = {}, textResources = [], windowId }) { + return ( + + + {textResources.map(text => ( + + ))} + + + + ); +} + +TextViewer.propTypes = { + textOptions: PropTypes.object, // eslint-disable-line react/forbid-prop-types + textResources: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types + windowId: PropTypes.string.isRequired, +}; diff --git a/src/containers/PrimaryWindow.js b/src/containers/PrimaryWindow.js index 1895e80539..02a36b7ed9 100644 --- a/src/containers/PrimaryWindow.js +++ b/src/containers/PrimaryWindow.js @@ -2,7 +2,8 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; import { withPlugins } from '../extend/withPlugins'; import { - getManifestoInstance, getVisibleCanvasAudioResources, getVisibleCanvasVideoResources, getWindow, + getManifestoInstance, getVisibleCanvasAudioResources, getVisibleCanvasTextResources, + getVisibleCanvasVideoResources, getWindow, } from '../state/selectors'; import { PrimaryWindow } from '../components/PrimaryWindow'; @@ -13,6 +14,7 @@ const mapStateToProps = (state, { windowId }) => { audioResources: getVisibleCanvasAudioResources(state, { windowId }) || [], isCollection: manifestoInstance && manifestoInstance.isCollection(), isCollectionDialogVisible: getWindow(state, { windowId }).collectionDialogOn, + textResources: getVisibleCanvasTextResources(state, { windowId }) || [], videoResources: getVisibleCanvasVideoResources(state, { windowId }) || [], }; }; diff --git a/src/containers/TextViewer.js b/src/containers/TextViewer.js new file mode 100644 index 0000000000..8a9a89987a --- /dev/null +++ b/src/containers/TextViewer.js @@ -0,0 +1,19 @@ +import { compose } from 'redux'; +import { withPlugins } from '../extend/withPlugins'; +import { TextViewer } from '../components/TextViewer'; + +/** */ +const mapStateToProps = (state, { windowId }) => ( + { + textOptions: getConfig(state).textOptions, + textResources: getVisibleCanvasTextResources(state, { windowId }) || [], + } +); + +const enhance = compose( + connect(mapStateToProps, null), + withPlugins('TextViewer'), + // further HOC go here +); + +export default enhance(TextViewer); diff --git a/src/lib/MiradorCanvas.js b/src/lib/MiradorCanvas.js index 6d09450e21..8032ad7067 100644 --- a/src/lib/MiradorCanvas.js +++ b/src/lib/MiradorCanvas.js @@ -1,6 +1,12 @@ import flatten from 'lodash/flatten'; import flattenDeep from 'lodash/flattenDeep'; import { Canvas, AnnotationPage, Annotation } from 'manifesto.js'; +import { + audioResourcesFrom, choiceResourcesFrom, hasImageService, imageResourcesFrom, iiifImageResourcesFrom, + textResourcesFrom, videoResourcesFrom, +} from './resourceFilters'; +import canvasTypes from './canvasTypes'; + /** * MiradorCanvas - adds additional, testable logic around Manifesto's Canvas * https://iiif-commons.github.io/manifesto/classes/_canvas_.manifesto.canvas.html @@ -68,7 +74,8 @@ export default class MiradorCanvas { get imageResources() { const resources = flattenDeep([ this.canvas.getImages().map(i => i.getResource()), - this.canvas.getContent().map(i => i.getBody()), + imageResourcesFrom(this.contentBodies), + choiceResourcesFrom(this.contentBodies), ]); return flatten(resources.map((resource) => { @@ -82,27 +89,30 @@ export default class MiradorCanvas { } /** */ - get videoResources() { - const resources = flattenDeep([ + get contentBodies() { + return flattenDeep([ this.canvas.getContent().map(i => i.getBody()), ]); - return flatten(resources.filter((resource) => resource.getProperty('type') === 'Video')); } /** */ - get audioResources() { - const resources = flattenDeep([ - this.canvas.getContent().map(i => i.getBody()), - ]); + get textResources() { + return textResourcesFrom(this.contentBodies); + } - return flatten(resources.filter((resource) => resource.getProperty('type') === 'Sound')); + /** */ + get videoResources() { + return flatten(videoResourcesFrom(this.contentBodies)); + } + + /** */ + get audioResources() { + return flatten(audioResourcesFrom(this.contentBodies)); } /** */ get v2VttContent() { - const resources = flattenDeep([ - this.canvas.getContent().map(i => i.getBody()), - ]); + const resources = this.contentBodies; return flatten(resources.filter((resource) => resource.getProperty('format') === 'text/vtt')); } @@ -159,12 +169,12 @@ export default class MiradorCanvas { /** */ get iiifImageResources() { return this.imageResources - .filter(r => r && r.getServices()[0] && r.getServices()[0].id); + .filter(hasImageService); } /** */ get imageServiceIds() { - return this.iiifImageResources.map(r => r.getServices()[0].id); + return this.iiifImageResources.map(hasImageService); } /** diff --git a/src/lib/canvasTypes.js b/src/lib/canvasTypes.js new file mode 100644 index 0000000000..71c9d24fe5 --- /dev/null +++ b/src/lib/canvasTypes.js @@ -0,0 +1,22 @@ +/** values for type/@type that indicate an image content resource */ +const imageTypes = ['Image', 'StillImage', 'dctypes:Image', 'dctypes:StillImage']; + +/** values for type/@type that indicate a sound content resource */ +const audioTypes = ['Audio', 'Sound', 'dctypes:Audio', 'dctypes:Sound']; + +/** values for type/@type that indicate a choice resource */ +const choiceTypes = ['oa:Choice']; + +/** values for type/@type that indicate a text content resource */ +const textTypes = ['Document', 'Text', 'dctypes:Document', 'dctypes:Text']; + +/** values for type/@type that indicate a video content resource */ +const videoTypes = ['Video', 'MovingImage', 'dctypes:Video', 'dctypes:MovingImage']; + +export default { + audioTypes, + choiceTypes, + imageTypes, + textTypes, + videoTypes, +}; diff --git a/src/lib/resourceFilters.js b/src/lib/resourceFilters.js new file mode 100644 index 0000000000..16e23f7a47 --- /dev/null +++ b/src/lib/resourceFilters.js @@ -0,0 +1,70 @@ +import canvasTypes from './canvasTypes'; +import serviceProfiles from './serviceProfiles'; + +/** + * Filter resources by profile property in given profiles + */ +export function filterByProfiles(resources, profiles) { + if (profiles === undefined || resources === undefined) return []; + + if (!Array.isArray(profiles)) { + return resources.filter((resource) => profiles === resource.getProperty('profile')); + } + + return resources.filter((resource) => profiles.includes(resource.getProperty('profile'))); +} + +/** + * Filter resources by type property in given types + */ +export function filterByTypes(resources, types) { + if (types === undefined || resources === undefined) return []; + + if (!Array.isArray(types)) { + return resources.filter((resource) => types === resource.getProperty('type')); + } + + return resources.filter((resource) => types.includes(resource.getProperty('type'))); +} + +/** */ +export function audioResourcesFrom(resources) { + return filterByTypes(resources, canvasTypes.audioTypes); +} + +/** */ +export function choiceResourcesFrom(resources) { + return filterByTypes(resources, canvasTypes.choiceTypes); +} + +/** + */ +export function imageServicesFrom(services) { + return filterByProfiles(services, serviceProfiles.iiifImageProfiles); +} + +/** */ +export function hasImageService(resource) { + const imageServices = imageServicesFrom(resource ? resource.getServices() : []); + return imageServices[0] && imageServices[0].id; +} + +/** */ +export function iiifImageResourcesFrom(resources) { + return imageResourcesFrom(resources).filter((r) => hasImageService(r)); +} + +/** */ +export function imageResourcesFrom(resources) { + return filterByTypes(resources, canvasTypes.imageTypes); +} + +/** */ +export function textResourcesFrom(resources) { + return filterByTypes(resources, canvasTypes.textTypes); +} + +/** */ +export function videoResourcesFrom(resources) { + return filterByTypes(resources, canvasTypes.videoTypes); +} diff --git a/src/lib/serviceProfiles.js b/src/lib/serviceProfiles.js new file mode 100644 index 0000000000..c73f95665f --- /dev/null +++ b/src/lib/serviceProfiles.js @@ -0,0 +1,13 @@ +/** values for profile that indicate an image service */ +const iiifImageProfiles = [ + 'level2', + 'level1', + 'level0', + 'http://iiif.io/api/image/2/level2.json', + 'http://iiif.io/api/image/2/level1.json', + 'http://iiif.io/api/image/2/level0.json', +]; + +export default { + iiifImageProfiles, +}; diff --git a/src/state/selectors/canvases.js b/src/state/selectors/canvases.js index 164c7408df..8d321f3b97 100644 --- a/src/state/selectors/canvases.js +++ b/src/state/selectors/canvases.js @@ -217,6 +217,20 @@ export const getVisibleCanvasNonTiledResources = createSelector( .filter(resource => resource.getServices().length < 1), ); +/** + * Returns visible canvas text resources. + * @param {object} state + * @param {string} windowId + * @return {Array} + */ +export const getVisibleCanvasTextResources = createSelector( + [ + getVisibleCanvases, + ], + canvases => flatten(canvases + .map(canvas => new MiradorCanvas(canvas).textResources)), +); + /** * Returns visible canvas video resources. * @param {object} state