Skip to content

Commit 093d303

Browse files
committed
Core mirador behaviors to provide a plugin target for text resources
- refactor type-based filters into a module - MiradorCanvas.imagesResources does not assume any service is an image service - stub TextViewer shows empty div, source elements for text resources, and canvas navigation - fixes #4085
1 parent a8daa02 commit 093d303

File tree

15 files changed

+395
-20
lines changed

15 files changed

+395
-20
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"@context": "http://iiif.io/api/presentation/3/context.json",
3+
"id": "https://iiif.io/api/cookbook/recipe/0001-text-pdf/manifest.json",
4+
"type": "Manifest",
5+
"label": {
6+
"en": [
7+
"Simplest Text Example 1"
8+
]
9+
},
10+
"items": [
11+
{
12+
"id": "https://iiif.io/api/cookbook/recipe/0001-text-pdf/canvas",
13+
"type": "Canvas",
14+
"items": [
15+
{
16+
"id": "https://iiif.io/api/cookbook/recipe/0001-text-pdf/canvas/page",
17+
"type": "AnnotationPage",
18+
"items": [
19+
{
20+
"id": "https://iiif.io/api/cookbook/recipe/0001-text-pdf/canvas/page/annotation",
21+
"type": "Annotation",
22+
"motivation": "painting",
23+
"body": {
24+
"id": "https://fixtures.iiif.io/other/UCLA/kabuki_ezukushi_rtl.pdf",
25+
"type": "Text",
26+
"format": "application/pdf"
27+
},
28+
"target": "https://iiif.io/api/cookbook/recipe/0001-text-pdf/canvas/page"
29+
}
30+
]
31+
}
32+
]
33+
}
34+
]
35+
}

__tests__/src/components/PrimaryWindow.test.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { render, screen, waitFor } from '@tests/utils/test-utils';
2+
import { Resource } from 'manifesto.js';
3+
import textManifest from '../../fixtures/version-3/text-pdf.json';
24
import { PrimaryWindow } from '../../../src/components/PrimaryWindow';
35

46
/** create wrapper */
@@ -48,4 +50,31 @@ describe('PrimaryWindow', () => {
4850
);
4951
await screen.findByRole('button', { accessibleName: 'show collection' });
5052
});
53+
it('should render a TextViewer when window has textResources but not audio, video or image', async () => {
54+
render(<div id="xyz" />);
55+
const manifests = {};
56+
const manifestId = 'https://iiif.io/api/cookbook/recipe/0001-text-pdf/manifest.json';
57+
manifests[manifestId] = {
58+
id: manifestId,
59+
json: textManifest,
60+
};
61+
const textResources = [true]; // this is a flag; the actual value will be given by a state selector against the preloaded state
62+
63+
const xyz = {
64+
manifestId: 'https://iiif.io/api/cookbook/recipe/0001-text-pdf/manifest.json',
65+
visibleCanvases: ['https://iiif.io/api/cookbook/recipe/0001-text-pdf/canvas'],
66+
};
67+
68+
render(
69+
<PrimaryWindow
70+
classes={{}}
71+
textResources={textResources}
72+
windowId="xyz"
73+
/>,
74+
{ preloadedState: { manifests, windows: { xyz } } },
75+
);
76+
await waitFor(() => {
77+
expect(document.querySelector('source:nth-of-type(1)')).toHaveAttribute('type', 'application/pdf'); // eslint-disable-line testing-library/no-node-access
78+
});
79+
});
5180
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { render, screen } from '@tests/utils/test-utils';
2+
import { TextViewer } from '../../../src/components/TextViewer';
3+
4+
/** create wrapper */
5+
function createWrapper(props, suspenseFallback) {
6+
return render(
7+
<TextViewer
8+
classes={{}}
9+
textOptions={{ crossOrigin: 'anonymous', 'data-testid': 'text' }}
10+
{...props}
11+
/>,
12+
);
13+
}
14+
15+
describe('TextViewer', () => {
16+
describe('render', () => {
17+
it('textResources as source elements', () => {
18+
createWrapper({
19+
textResources: [
20+
{ getFormat: () => 'application/pdf', getType: () => 'Text', id: 1 },
21+
],
22+
windowId: 'a',
23+
}, true);
24+
const text = screen.getByTestId('text');
25+
expect(text.querySelector('source:nth-of-type(1)')).toHaveAttribute('type', 'application/pdf'); // eslint-disable-line testing-library/no-node-access
26+
});
27+
it('passes through configurable options', () => {
28+
createWrapper({
29+
textResources: [
30+
{ getFormat: () => 'application/pdf', getType: () => 'Text', id: 1 },
31+
],
32+
windowId: 'a',
33+
}, true);
34+
expect(screen.getByTestId('text')).toHaveAttribute('crossOrigin', 'anonymous');
35+
});
36+
it('canvas navigation', () => {
37+
createWrapper({
38+
textResources: [
39+
{ getFormat: () => 'application/pdf', getType: () => 'Text', id: 1 },
40+
],
41+
windowId: 'a',
42+
}, true);
43+
const text = screen.getByTestId('text');
44+
expect(text.querySelector('.mirador-canvas-nav')).toBeDefined(); // eslint-disable-line testing-library/no-node-access
45+
});
46+
});
47+
});

__tests__/src/lib/MiradorCanvas.test.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import otherContentStringsFixture from '../../fixtures/version-2/BibliographicRe
77
import fragmentFixture from '../../fixtures/version-2/hamilton.json';
88
import fragmentFixtureV3 from '../../fixtures/version-3/hamilton.json';
99
import audioFixture from '../../fixtures/version-3/0002-mvm-audio.json';
10+
import textFixture from '../../fixtures/version-3/text-pdf.json';
1011
import videoFixture from '../../fixtures/version-3/0015-start.json';
1112
import videoWithAnnoCaptions from '../../fixtures/version-3/video_with_annotation_captions.json';
1213

@@ -133,4 +134,12 @@ describe('MiradorCanvas', () => {
133134
expect(instance.v3VttContent.length).toEqual(1);
134135
});
135136
});
137+
describe('textResources', () => {
138+
it('returns text', () => {
139+
instance = new MiradorCanvas(
140+
Utils.parseManifest(textFixture).getSequences()[0].getCanvases()[0],
141+
);
142+
expect(instance.textResources.length).toEqual(1);
143+
});
144+
});
136145
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Utils } from 'manifesto.js';
2+
import flattenDeep from 'lodash/flattenDeep';
3+
import manifestFixture019 from '../../fixtures/version-2/019.json';
4+
import {
5+
filterByProfiles, filterByTypes,
6+
} from '../../../src/lib/resourceFilters';
7+
8+
describe('resourceFilters', () => {
9+
let canvas;
10+
beforeEach(() => {
11+
[canvas] = Utils.parseManifest(manifestFixture019).getSequences()[0].getCanvases();
12+
});
13+
describe('filterByProfiles', () => {
14+
it('filters resources', () => {
15+
const services = flattenDeep(canvas.resourceAnnotations.map((a) => a.getResource().getServices()));
16+
expect(filterByProfiles(services, 'http://iiif.io/api/image/2/level2.json').map((s) => s.id)).toEqual([
17+
'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44',
18+
]);
19+
expect(filterByProfiles(services, 'http://nonexistent.io/api/service.json').map((s) => s.id)).toEqual([]);
20+
});
21+
});
22+
describe('filterByTypes', () => {
23+
it('filters resources', () => {
24+
const resources = flattenDeep(canvas.resourceAnnotations.map((a) => a.getResource()));
25+
expect(filterByTypes(resources, 'dctypes:Image').map((r) => r.id)).toEqual([
26+
'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/full/0/default.jpg',
27+
]);
28+
expect(filterByTypes(resources, 'Nonexistent').map((r) => r.id)).toEqual([]);
29+
});
30+
});
31+
});

__tests__/src/selectors/canvases.test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import manifestFixture019 from '../../fixtures/version-2/019.json';
33
import minimumRequired from '../../fixtures/version-2/minimumRequired.json';
44
import minimumRequired3 from '../../fixtures/version-3/minimumRequired.json';
55
import audioFixture from '../../fixtures/version-3/0002-mvm-audio.json';
6+
import textFixture from '../../fixtures/version-3/text-pdf.json';
67
import videoFixture from '../../fixtures/version-3/0015-start.json';
78
import videoWithAnnoCaptions from '../../fixtures/version-3/video_with_annotation_captions.json';
89
import settings from '../../../src/config/settings';
@@ -15,6 +16,7 @@ import {
1516
getCanvasLabel,
1617
selectInfoResponse,
1718
getVisibleCanvasNonTiledResources,
19+
getVisibleCanvasTextResources,
1820
getVisibleCanvasVideoResources,
1921
getVisibleCanvasAudioResources,
2022
getVisibleCanvasCaptions,
@@ -462,4 +464,26 @@ describe('getVisibleCanvasNonTiledResources', () => {
462464
expect(getVisibleCanvasAudioResources(state, { windowId: 'a' })[0].id).toBe('https://fixtures.iiif.io/audio/indiana/mahler-symphony-3/CD1/medium/128Kbps.mp4');
463465
});
464466
});
467+
468+
describe('getVisibleCanvasTextResources', () => {
469+
it('returns canvases resources', () => {
470+
const state = {
471+
manifests: {
472+
'https://iiif.io/api/cookbook/recipe/0001-text-pdf/manifest.json': {
473+
id: 'https://iiif.io/api/cookbook/recipe/0001-text-pdf/manifest.json',
474+
json: textFixture,
475+
},
476+
},
477+
windows: {
478+
a: {
479+
manifestId: 'https://iiif.io/api/cookbook/recipe/0001-text-pdf/manifest.json',
480+
visibleCanvases: [
481+
'https://iiif.io/api/cookbook/recipe/0001-text-pdf/canvas',
482+
],
483+
},
484+
},
485+
};
486+
expect(getVisibleCanvasTextResources(state, { windowId: 'a' })[0].id).toBe('https://fixtures.iiif.io/other/UCLA/kabuki_ezukushi_rtl.pdf');
487+
});
488+
});
465489
});

src/components/PrimaryWindow.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const GalleryView = lazy(() => import('../containers/GalleryView'));
1212
const SelectCollection = lazy(() => import('../containers/SelectCollection'));
1313
const WindowViewer = lazy(() => import('../containers/WindowViewer'));
1414
const VideoViewer = lazy(() => import('../containers/VideoViewer'));
15+
const TextViewer = lazy(() => import('../containers/TextViewer'));
1516

1617
GalleryView.displayName = 'GalleryView';
1718
SelectCollection.displayName = 'SelectCollection';
@@ -25,8 +26,8 @@ const Root = styled('div', { name: 'PrimaryWindow', slot: 'root' })(() => ({
2526

2627
/** */
2728
const TypeSpecificViewer = ({
28-
audioResources = [], isCollection = false,
29-
isFetching = false, videoResources = [], view = undefined, windowId,
29+
audioResources = [], isCollection = false, isFetching = false, textResources = [],
30+
videoResources = [], view = undefined, windowId,
3031
}) => {
3132
if (isCollection) {
3233
return (
@@ -57,6 +58,13 @@ const TypeSpecificViewer = ({
5758
/>
5859
);
5960
}
61+
if (textResources.length > 0) {
62+
return (
63+
<TextViewer
64+
windowId={windowId}
65+
/>
66+
);
67+
}
6068
return (
6169
<WindowViewer
6270
windowId={windowId}
@@ -70,6 +78,7 @@ TypeSpecificViewer.propTypes = {
7078
audioResources: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
7179
isCollection: PropTypes.bool,
7280
isFetching: PropTypes.bool,
81+
textResources: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
7382
videoResources: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
7483
view: PropTypes.string,
7584
windowId: PropTypes.string.isRequired,
@@ -80,13 +89,15 @@ TypeSpecificViewer.propTypes = {
8089
* window. Right now this differentiates between a Image, Video, or Audio viewer.
8190
*/
8291
export function PrimaryWindow({
83-
audioResources = undefined, isCollection = false, isFetching = false, videoResources = undefined,
84-
view = undefined, windowId, isCollectionDialogVisible = false, children = null, className = undefined,
92+
audioResources = undefined, children = null, className = undefined, isCollection = false,
93+
isCollectionDialogVisible = false, isFetching = false, textResources = undefined, videoResources = undefined,
94+
view = undefined, windowId,
8595
}) {
8696
const viewerProps = {
8797
audioResources,
8898
isCollection,
8999
isFetching,
100+
textResources,
90101
videoResources,
91102
view,
92103
windowId,
@@ -111,6 +122,7 @@ PrimaryWindow.propTypes = {
111122
isCollection: PropTypes.bool,
112123
isCollectionDialogVisible: PropTypes.bool,
113124
isFetching: PropTypes.bool,
125+
textResources: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
114126
videoResources: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
115127
view: PropTypes.string,
116128
windowId: PropTypes.string.isRequired,

src/components/TextViewer.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { styled } from '@mui/material/styles';
2+
import PropTypes from 'prop-types';
3+
import WindowCanvasNavigationControls from '../containers/WindowCanvasNavigationControls';
4+
5+
const StyledContainer = styled('div')(() => ({
6+
alignItems: 'center',
7+
display: 'flex',
8+
width: '100%',
9+
}));
10+
11+
const StyledText = styled('div')(() => ({
12+
maxHeight: '100%',
13+
width: '100%',
14+
}));
15+
16+
/**
17+
* Simple divs with canvas navigation, which should mimic v3 fallthrough to WindowViewer
18+
* with non-image resources and provide a target for plugin overrides with minimal disruption.
19+
*/
20+
export function TextViewer({ textOptions = {}, textResources = [], windowId }) {
21+
return (
22+
<StyledContainer>
23+
<StyledText {...textOptions}>
24+
{textResources.map(text => (
25+
<source key={text.id} src={text.id} type={text.getFormat()} />
26+
))}
27+
<WindowCanvasNavigationControls windowId={windowId} />
28+
</StyledText>
29+
</StyledContainer>
30+
);
31+
}
32+
33+
TextViewer.propTypes = {
34+
textOptions: PropTypes.object, // eslint-disable-line react/forbid-prop-types
35+
textResources: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
36+
windowId: PropTypes.string.isRequired,
37+
};

src/containers/PrimaryWindow.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { compose } from 'redux';
22
import { connect } from 'react-redux';
33
import { withPlugins } from '../extend/withPlugins';
44
import {
5-
getManifestoInstance, getVisibleCanvasAudioResources, getVisibleCanvasVideoResources, getWindow,
5+
getManifestoInstance, getVisibleCanvasAudioResources, getVisibleCanvasTextResources,
6+
getVisibleCanvasVideoResources, getWindow,
67
} from '../state/selectors';
78
import { PrimaryWindow } from '../components/PrimaryWindow';
89

@@ -13,6 +14,7 @@ const mapStateToProps = (state, { windowId }) => {
1314
audioResources: getVisibleCanvasAudioResources(state, { windowId }) || [],
1415
isCollection: manifestoInstance && manifestoInstance.isCollection(),
1516
isCollectionDialogVisible: getWindow(state, { windowId }).collectionDialogOn,
17+
textResources: getVisibleCanvasTextResources(state, { windowId }) || [],
1618
videoResources: getVisibleCanvasVideoResources(state, { windowId }) || [],
1719
};
1820
};

src/containers/TextViewer.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { compose } from 'redux';
2+
import { connect } from 'react-redux';
3+
import { withPlugins } from '../extend/withPlugins';
4+
import { getConfig, getVisibleCanvasTextResources } from '../state/selectors';
5+
import { TextViewer } from '../components/TextViewer';
6+
7+
/** */
8+
const mapStateToProps = (state, { windowId }) => (
9+
{
10+
textOptions: getConfig(state).textOptions,
11+
textResources: getVisibleCanvasTextResources(state, { windowId }) || [],
12+
}
13+
);
14+
15+
const enhance = compose(
16+
connect(mapStateToProps, null),
17+
withPlugins('TextViewer'),
18+
// further HOC go here
19+
);
20+
21+
export default enhance(TextViewer);

0 commit comments

Comments
 (0)