diff --git a/__tests__/fixtures/version-2/annotationMiradorDual.json b/__tests__/fixtures/version-2/annotationMiradorDual.json new file mode 100644 index 0000000000000000000000000000000000000000..4eced583f30a3c00262c36e0e58f6d1a8f139a40 --- /dev/null +++ b/__tests__/fixtures/version-2/annotationMiradorDual.json @@ -0,0 +1,34 @@ +{ + "@context": "http://iiif.io/api/presentation/2/context.json", + "@type": "oa:Annotation", + "motivation": [ + "oa:commenting" + ], + "resource": [ + { + "@type": "dctypes:Text", + "format": "text/html", + "chars": "<p>something</p>" + } + ], + "on": { + "@type": "oa:SpecificResource", + "full": "https://oculus-dev.harvardx.harvard.edu/manifests/huam:320567/canvas/canvas-10466656.json", + "selector": { + "@type": "oa:Choice", + "default": { + "@type": "oa:FragmentSelector", + "value": "xywh=1000,219,198,148" + }, + "item": { + "@type": "oa:SvgSelector", + "value": "<svg xmlns='http://www.w3.org/2000/svg'><path xmlns=\"http://www.w3.org/2000/svg\" d=\"M1000.24213,219.15375l98.78935,0l0,0l98.78935,0l0,74.09201l0,74.09201l-98.78935,0l-98.78935,0l0,-74.09201z\" data-paper-data=\"{"defaultStrokeValue":1,"editStrokeValue":5,"currentStrokeValue":5,"rotation":0,"annotation":null,"editable":true}\" id=\"rectangle_7e2b56fa-b18b-4d09-a575-0bb19f560b56\" fill-opacity=\"0\" fill=\"#00bfff\" fill-rule=\"nonzero\" stroke=\"#00bfff\" stroke-width=\"30.87167\" stroke-linecap=\"butt\" stroke-linejoin=\"miter\" stroke-miterlimit=\"10\" stroke-dasharray=\"\" stroke-dashoffset=\"0\" font-family=\"sans-serif\" font-weight=\"normal\" font-size=\"12\" text-anchor=\"start\" style=\"mix-blend-mode: normal\"/></svg>" + } + }, + "within": { + "@id": "https://oculus-dev.harvardx.harvard.edu/manifests/huam:320567", + "@type": "sc:Manifest" + } + }, + "@id": "d2eda2e2-951a-4f88-b4ec-03a7b25a5d07" +} diff --git a/__tests__/integration/mirador/svg_annos.html b/__tests__/integration/mirador/svg_annos.html new file mode 100644 index 0000000000000000000000000000000000000000..201cb24afa65e0d1f5d63e0a8ea6bae70291d51f --- /dev/null +++ b/__tests__/integration/mirador/svg_annos.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <meta name="theme-color" content="#000000"> + <title>Mirador</title> + <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"> + </head> + <body> + <div id="mirador" style="position: absolute; top: 0; bottom: 0; left: 0; right: 0;"></div> + <script>document.write("<script type='text/javascript' src='../../../dist/mirador.min.js?v=" + Date.now() + "'><\/script>");</script> + <script type="text/javascript"> + var miradorInstance = Mirador.viewer({ + id: 'mirador', + windows: [ + { + manifestId: 'https://api.myjson.com/bins/ahd5y', + }, + { + manifestId: 'https://iiif.bodleian.ox.ac.uk/iiif/manifest/748a9d50-5a3a-440e-ab9d-567dd68b6abb.json', + canvasId: 'https://iiif.bodleian.ox.ac.uk/iiif/canvas/4a4d7347-0c32-4e3d-b517-5042eba06c25.json', + } + ], + window: { + sideBarOpenByDefault: true, + defaultSideBarPanel: 'annotations' + }, + }); + </script> + </body> +</html> diff --git a/__tests__/src/components/OpenSeadragonViewer.test.js b/__tests__/src/components/OpenSeadragonViewer.test.js index b63427b1723e0c4f96f2aed31dedecc63a285cc6..4822d4178d4d7b25e4c7aa296cd83788ec3b3e00 100644 --- a/__tests__/src/components/OpenSeadragonViewer.test.js +++ b/__tests__/src/components/OpenSeadragonViewer.test.js @@ -355,7 +355,7 @@ describe('OpenSeadragonViewer', () => { wrapper.instance().annotationsToContext(annotations); const context = wrapper.instance().osdCanvasOverlay.context2d; expect(context.strokeStyle).toEqual('yellow'); - expect(context.lineWidth).toEqual(4); + expect(context.lineWidth).toEqual(20); expect(strokeRect).toHaveBeenCalledWith(10, 10, 100, 200); }); }); diff --git a/__tests__/src/lib/AnnotationResource.test.js b/__tests__/src/lib/AnnotationResource.test.js index f9df4d7ab066810b0a4b64dd0fff27071c823b81..08dc0a0d79b9eced4cc3fc0ddb3b7493c896e3d6 100644 --- a/__tests__/src/lib/AnnotationResource.test.js +++ b/__tests__/src/lib/AnnotationResource.test.js @@ -122,4 +122,20 @@ describe('AnnotationResource', () => { .fragmentSelector).toEqual([10, 10, 100, 200]); }); }); + describe('svgSelector', () => { + it('simple string', () => { + expect(new AnnotationResource({ on: 'www.example.com/#xywh=10,10,100,200' }) + .svgSelector).toEqual(null); + }); + + it('array of selectors', () => { + expect(new AnnotationResource({ on: [{ selector: { item: { '@type': 'oa:SvgSelector' } } }] }) + .svgSelector).toEqual({ '@type': 'oa:SvgSelector' }); + }); + + it('without specified type', () => { + expect(new AnnotationResource({ on: [{ selector: { item: {} } }] }) + .svgSelector).toEqual(null); + }); + }); }); diff --git a/__tests__/src/lib/CanvasAnnotationDisplay.test.js b/__tests__/src/lib/CanvasAnnotationDisplay.test.js new file mode 100644 index 0000000000000000000000000000000000000000..e122100e8aad9a5584cbf160e949ca37e568329a --- /dev/null +++ b/__tests__/src/lib/CanvasAnnotationDisplay.test.js @@ -0,0 +1,83 @@ +import CanvasAnnotationDisplay from '../../../src/lib/CanvasAnnotationDisplay'; +import AnnotationResource from '../../../src/lib/AnnotationResource'; +import dualStrategyAnno from '../../fixtures/version-2/annotationMiradorDual.json'; + +/** */ +function createSubject(args) { + return new CanvasAnnotationDisplay({ + color: 'blue', + offset: { + x: -100, + y: 0, + }, + zoom: 0.0005, + ...args, + }); +} + +describe('CanvasAnnotationDisplay', () => { + describe('toContext', () => { + it('selects svgSelector if present in a dual anno', () => { + const context = { + stroke: jest.fn(), + }; + const subject = createSubject({ + resource: new AnnotationResource(dualStrategyAnno), + }); + subject.svgContext = jest.fn(); + subject.fragmentContext = jest.fn(); + subject.toContext(context); + expect(subject.svgContext).toHaveBeenCalled(); + expect(subject.fragmentContext).not.toHaveBeenCalled(); + }); + it('selects fragmentSelector if no svg present', () => { + const context = { + stroke: jest.fn(), + }; + const subject = createSubject({ + resource: new AnnotationResource({ on: 'www.example.com/#xywh=10,10,100,200' }), + }); + subject.svgContext = jest.fn(); + subject.fragmentContext = jest.fn(); + subject.toContext(context); + expect(subject.svgContext).not.toHaveBeenCalled(); + expect(subject.fragmentContext).toHaveBeenCalled(); + }); + }); + describe('svgString', () => { + it('selects the svg selector string value', () => { + const subject = createSubject({ + resource: new AnnotationResource(dualStrategyAnno), + }); + expect(subject.svgString).toMatch(/<svg/); + }); + }); + describe('svgContext', () => { + it('draws the paths with selected arguments', () => { + const context = { + stroke: jest.fn(), + }; + const subject = createSubject({ + resource: new AnnotationResource(dualStrategyAnno), + }); + subject.svgContext(context); + expect(context.stroke).toHaveBeenCalledWith({}); + expect(context.strokeStyle).toEqual('blue'); + expect(context.lineWidth).toEqual(20); + }); + }); + describe('fragmentContext', () => { + it('draws the fragment with selected arguments', () => { + const context = { + strokeRect: jest.fn(), + }; + const subject = createSubject({ + resource: new AnnotationResource({ on: 'www.example.com/#xywh=10,10,100,200' }), + }); + subject.fragmentContext(context); + expect(context.strokeRect).toHaveBeenCalledWith(-90, 10, 100, 200); + expect(context.strokeStyle).toEqual('blue'); + expect(context.lineWidth).toEqual(20); + }); + }); +}); diff --git a/setupJest.js b/setupJest.js index 6ccce82a5733d3d111de160410d7c706a129da0a..b88cdf880517460401c82c05aff889f86af7d3cf 100644 --- a/setupJest.js +++ b/setupJest.js @@ -32,6 +32,11 @@ class IntersectionObserverPolyfill { global.IntersectionObserver = IntersectionObserverPolyfill; +/** */ +function Path2D() { +} + +global.Path2D = Path2D; /** * copy object property descriptors from `src` to `target` * @param {*} src diff --git a/src/components/OpenSeadragonViewer.js b/src/components/OpenSeadragonViewer.js index 487f5ca0fdd2f477b0064e3a78bc901264cec87f..94538fe2ce6303ff263ba71e1aba948cd1bd3a3a 100644 --- a/src/components/OpenSeadragonViewer.js +++ b/src/components/OpenSeadragonViewer.js @@ -6,6 +6,7 @@ import classNames from 'classnames'; import ns from '../config/css-ns'; import OpenSeadragonCanvasOverlay from '../lib/OpenSeadragonCanvasOverlay'; import CanvasWorld from '../lib/CanvasWorld'; +import CanvasAnnotationDisplay from '../lib/CanvasAnnotationDisplay'; /** * Represents a OpenSeadragonViewer in the mirador workspace. Responsible for mounting @@ -183,16 +184,14 @@ export class OpenSeadragonViewer extends Component { const { canvasWorld } = this.props; const context = this.osdCanvasOverlay.context2d; const zoom = this.viewer.viewport.getZoom(true); - const width = canvasWorld.worldBounds()[2]; annotations.forEach((annotation) => { annotation.resources.forEach((resource) => { if (!canvasWorld.canvasIds.includes(resource.targetId)) return; const offset = canvasWorld.offsetByCanvas(resource.targetId); - const fragment = resource.fragmentSelector; - fragment[0] += offset.x; - context.strokeStyle = color; - context.lineWidth = Math.ceil(10 / (zoom * width)); - context.strokeRect(...fragment); + const canvasAnnotationDisplay = new CanvasAnnotationDisplay({ + color, offset, resource, zoom, + }); + canvasAnnotationDisplay.toContext(context); }); }); } diff --git a/src/lib/AnnotationResource.js b/src/lib/AnnotationResource.js index 3ed87bc1ef0313ef21841fdfc716ebbb70195b7b..d2bb62a644d26b33f34d2d3b0e4d9ab6f4354d33 100644 --- a/src/lib/AnnotationResource.js +++ b/src/lib/AnnotationResource.js @@ -68,6 +68,23 @@ export default class AnnotationResource { } } + /** */ + get svgSelector() { + const on = this.on[0]; + + switch (typeof on) { + case 'string': + return null; + case 'object': + if (on.selector && on.selector.item && on.selector.item['@type'] === 'oa:SvgSelector') { + return on.selector.item; + } + return null; + default: + return null; + } + } + /** */ get fragmentSelector() { const { selector } = this; diff --git a/src/lib/CanvasAnnotationDisplay.js b/src/lib/CanvasAnnotationDisplay.js new file mode 100644 index 0000000000000000000000000000000000000000..2d5fa7995c010c433c13a13ff297a01b528d1f9b --- /dev/null +++ b/src/lib/CanvasAnnotationDisplay.js @@ -0,0 +1,72 @@ +/** + * CanvasAnnotationDisplay - class used to display a SVG and fragment based + * annotations. + */ +export default class CanvasAnnotationDisplay { + /** */ + constructor({ + resource, color, zoom, offset, + }) { + this.resource = resource; + this.color = color; + this.zoom = zoom; + this.offset = offset; + } + + /** */ + toContext(context) { + if (this.resource.svgSelector) { + this.svgContext(context); + } else { + this.fragmentContext(context); + } + } + + /** */ + get svgString() { + return this.resource.svgSelector.value; + } + + /** */ + svgContext(context) { + [...this.svgPaths].forEach((element) => { + /** + * Note: Path2D is not supported in IE11. + * TODO: Support multi canvas offset + * One example: https://developer.mozilla.org/en-US/docs/Web/API/Path2D/addPath + */ + const p = new Path2D(element.attributes.d.nodeValue); + /** + * Note: we could do something to return the svg styling attributes as + * some have encoded information in these values. However, how should we + * handle highlighting and other complications? + * context.strokeStyle = element.attributes.stroke.nodeValue; + * context.lineWidth = element.attributes['stroke-width'].nodeValue; + */ + context.strokeStyle = this.color; // eslint-disable-line no-param-reassign + context.lineWidth = this.lineWidth(); // eslint-disable-line no-param-reassign + context.stroke(p); + }); + } + + /** */ + fragmentContext(context) { + const fragment = this.resource.fragmentSelector; + fragment[0] += this.offset.x; + context.strokeStyle = this.color; // eslint-disable-line no-param-reassign + context.lineWidth = this.lineWidth(); // eslint-disable-line no-param-reassign + context.strokeRect(...fragment); + } + + /** */ + lineWidth() { + return Math.ceil(1 / (this.zoom * 100)); + } + + /** */ + get svgPaths() { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(this.svgString, 'text/xml'); + return xmlDoc.getElementsByTagName('path'); + } +}