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